Add UI improvements + refactor frontend to event-driven architecture
Some checks failed
CI / ci (push) Failing after 1m9s
Some checks failed
CI / ci (push) Failing after 1m9s
Features: disabled place button during cooldown, beep on cooldown end, hand/paint mode toggle with Space shortcut, soldier count pop animation. Architecture: centralized DOM refs, AppState store replacing localStorage polling, event bus decoupling socket from handlers, keyboard manager, tool manager with Strategy pattern (paint-tool, hand-tool).
This commit is contained in:
@@ -1,35 +1,118 @@
|
||||
import { createSocketConnection } from "./infrastructure/socket-client.js";
|
||||
import { checkServer } from "./infrastructure/api.js";
|
||||
import { createEventBus } from "./infrastructure/event-bus.js";
|
||||
import { createKeyboard } from "./infrastructure/keyboard.js";
|
||||
import { createAppState } from "./domain/app-state.js";
|
||||
import { createCanvasRenderer } from "./ui/canvas-renderer.js";
|
||||
import { createColorPalette } from "./ui/color-palette.js";
|
||||
import { createPixelPlacer } from "./ui/pixel-placer.js";
|
||||
import { startCooldownDisplay } from "./ui/cooldown-display.js";
|
||||
import { createCanvasViewport } from "./ui/canvas-viewport.js";
|
||||
import { createToolManager } from "./ui/tool-manager.js";
|
||||
import { createPaintTool } from "./tools/paint-tool.js";
|
||||
import { createHandTool } from "./tools/hand-tool.js";
|
||||
import { setPixel } from "./domain/canvas-state.js";
|
||||
import { getCanvasCoords } from "./domain/coords.js";
|
||||
|
||||
const canvasEl = document.getElementById("canvas");
|
||||
const coordsText = document.getElementById("coords");
|
||||
const currentSoldiersSpan = document.getElementById("current-soldiers");
|
||||
const statusEl = document.getElementById("connection-status");
|
||||
const els = {
|
||||
canvas: document.getElementById("canvas"),
|
||||
viewport: document.getElementById("canvas-viewport"),
|
||||
status: document.getElementById("connection-status"),
|
||||
countdown: document.getElementById("countdown"),
|
||||
placeBtn: document.getElementById("place-pixel"),
|
||||
soldiers: document.getElementById("current-soldiers"),
|
||||
coords: document.getElementById("coords"),
|
||||
zoomIn: document.getElementById("zoom-in"),
|
||||
zoomOut: document.getElementById("zoom-out"),
|
||||
zoomLabel: document.getElementById("zoom-level"),
|
||||
modePaint: document.getElementById("mode-paint"),
|
||||
modeHand: document.getElementById("mode-hand"),
|
||||
colorPicker: document.getElementById("color-picker"),
|
||||
colorSpan: document.getElementById("current-color-span"),
|
||||
saveCanvas: document.getElementById("save-canvas"),
|
||||
};
|
||||
|
||||
const savedDisplay = canvasEl.style.display;
|
||||
canvasEl.style.display = "none";
|
||||
const savedDisplay = els.canvas.style.display;
|
||||
els.canvas.style.display = "none";
|
||||
|
||||
let canvasState = [];
|
||||
|
||||
const renderer = createCanvasRenderer(canvasEl);
|
||||
const palette = createColorPalette();
|
||||
|
||||
startCooldownDisplay();
|
||||
createCanvasViewport(canvasEl);
|
||||
|
||||
canvasEl.addEventListener("mousemove", (event) => {
|
||||
const { x, y } = getCanvasCoords(event, canvasEl);
|
||||
coordsText.textContent = `${x}, ${y}`;
|
||||
const appState = createAppState({
|
||||
canvasPixels: [],
|
||||
lastPlacementTime: parseInt(localStorage.getItem("lastPixelTime") || "0"),
|
||||
selectedColor: localStorage.getItem("currentColor") || "#000000",
|
||||
soldierCount: 0,
|
||||
});
|
||||
|
||||
document.getElementById("save-canvas").addEventListener("click", () => {
|
||||
appState.subscribe("lastPlacementTime", (val) => {
|
||||
localStorage.setItem("lastPixelTime", val.toString());
|
||||
});
|
||||
|
||||
appState.subscribe("selectedColor", (val) => {
|
||||
localStorage.setItem("currentColor", val);
|
||||
});
|
||||
|
||||
const bus = createEventBus();
|
||||
const keyboard = createKeyboard();
|
||||
const renderer = createCanvasRenderer(els.canvas);
|
||||
|
||||
createColorPalette({
|
||||
colorPicker: els.colorPicker,
|
||||
colorSpan: els.colorSpan,
|
||||
appState,
|
||||
});
|
||||
|
||||
startCooldownDisplay({
|
||||
countdown: els.countdown,
|
||||
placeBtn: els.placeBtn,
|
||||
appState,
|
||||
});
|
||||
|
||||
createCanvasViewport({
|
||||
canvas: els.canvas,
|
||||
viewport: els.viewport,
|
||||
zoomIn: els.zoomIn,
|
||||
zoomOut: els.zoomOut,
|
||||
zoomLabel: els.zoomLabel,
|
||||
});
|
||||
|
||||
const updateModeButtons = (name) => {
|
||||
els.modePaint.classList.toggle("ring-2", name === "paint");
|
||||
els.modePaint.classList.toggle("ring-offset-2", name === "paint");
|
||||
els.modePaint.classList.toggle("ring-cyan-400", name === "paint");
|
||||
els.modeHand.classList.toggle("ring-2", name === "hand");
|
||||
els.modeHand.classList.toggle("ring-offset-2", name === "hand");
|
||||
els.modeHand.classList.toggle("ring-cyan-400", name === "hand");
|
||||
};
|
||||
|
||||
const toolManager = createToolManager({
|
||||
canvas: els.canvas,
|
||||
viewport: els.viewport,
|
||||
onActiveChange: updateModeButtons,
|
||||
});
|
||||
|
||||
toolManager.register(
|
||||
createPaintTool({ canvas: els.canvas, renderer, appState, bus }),
|
||||
);
|
||||
toolManager.register(createHandTool({ viewport: els.viewport }));
|
||||
toolManager.setActive("paint");
|
||||
|
||||
els.modePaint.addEventListener("click", () => toolManager.setActive("paint"));
|
||||
els.modeHand.addEventListener("click", () => toolManager.setActive("hand"));
|
||||
els.placeBtn.addEventListener("click", () => toolManager.confirm());
|
||||
|
||||
keyboard.register("Enter", "confirm-placement");
|
||||
keyboard.register("Escape", "cancel-preview");
|
||||
keyboard.register(" ", "hold-hand-mode", { hold: true });
|
||||
|
||||
keyboard.onAction("confirm-placement", () => toolManager.confirm());
|
||||
keyboard.onAction("cancel-preview", () => toolManager.cancel());
|
||||
keyboard.onAction("hold-hand-mode:down", () => toolManager.holdTool("hand"));
|
||||
keyboard.onAction("hold-hand-mode:up", () => toolManager.releaseTool());
|
||||
|
||||
els.canvas.addEventListener("mousemove", (event) => {
|
||||
const { x, y } = getCanvasCoords(event, els.canvas);
|
||||
els.coords.textContent = `${x}, ${y}`;
|
||||
});
|
||||
|
||||
els.saveCanvas.addEventListener("click", () => {
|
||||
const a = document.createElement("a");
|
||||
a.href = renderer.toDataURL();
|
||||
a.download = "canvas.png";
|
||||
@@ -37,50 +120,65 @@ document.getElementById("save-canvas").addEventListener("click", () => {
|
||||
});
|
||||
|
||||
const showStatus = (message, isError) => {
|
||||
if (!statusEl) return;
|
||||
statusEl.textContent = message;
|
||||
statusEl.className = isError
|
||||
els.status.textContent = message;
|
||||
els.status.className = isError
|
||||
? "text-red-500 text-sm"
|
||||
: "text-green-500 text-sm";
|
||||
};
|
||||
|
||||
els.soldiers.addEventListener("animationend", () => {
|
||||
els.soldiers.classList.remove("soldier-pop");
|
||||
});
|
||||
|
||||
appState.subscribe("soldierCount", (count, prev) => {
|
||||
els.soldiers.textContent = count;
|
||||
if (count > prev && prev > 0) {
|
||||
els.soldiers.classList.remove("soldier-pop");
|
||||
void els.soldiers.offsetWidth;
|
||||
els.soldiers.classList.add("soldier-pop");
|
||||
}
|
||||
});
|
||||
|
||||
bus.on("canvas_state", (data) => {
|
||||
appState.set("canvasPixels", data);
|
||||
renderer.drawState(data);
|
||||
});
|
||||
|
||||
bus.on("pixel-updated", (update) => {
|
||||
renderer.drawPixel(update.x, update.y, update.color);
|
||||
setPixel(appState.get("canvasPixels"), update.x, update.y, update.color);
|
||||
});
|
||||
|
||||
bus.on("current_soldiers", (count) => {
|
||||
appState.set("soldierCount", count);
|
||||
});
|
||||
|
||||
bus.on("connect", () => {
|
||||
els.canvas.style.display = savedDisplay;
|
||||
showStatus("Connected", false);
|
||||
});
|
||||
|
||||
bus.on("disconnect", () => {
|
||||
showStatus("Disconnected — reconnecting...", true);
|
||||
});
|
||||
|
||||
bus.on("error", (message) => showStatus(message, true));
|
||||
|
||||
checkServer()
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error("Server unavailable");
|
||||
|
||||
const socket = createSocketConnection();
|
||||
|
||||
socket.on("connect", () => {
|
||||
canvasEl.style.display = savedDisplay;
|
||||
showStatus("Connected", false);
|
||||
});
|
||||
socket.on("connect", () => bus.emit("connect"));
|
||||
socket.on("canvas_state", (data) => bus.emit("canvas_state", data));
|
||||
socket.on("pixel-updated", (data) => bus.emit("pixel-updated", data));
|
||||
socket.on("current_soldiers", (count) => bus.emit("current_soldiers", count));
|
||||
socket.on("error", (message) => bus.emit("error", message));
|
||||
socket.on("disconnect", () => bus.emit("disconnect"));
|
||||
|
||||
socket.on("canvas_state", (data) => {
|
||||
canvasState = data;
|
||||
renderer.drawState(data);
|
||||
});
|
||||
|
||||
socket.on("error", (message) => showStatus(message, true));
|
||||
|
||||
socket.on("current_soldiers", (count) => {
|
||||
currentSoldiersSpan.textContent = count;
|
||||
});
|
||||
|
||||
socket.on("pixel-updated", (update) => {
|
||||
renderer.drawPixel(update.x, update.y, update.color);
|
||||
setPixel(canvasState, update.x, update.y, update.color);
|
||||
});
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
showStatus("Disconnected — reconnecting...", true);
|
||||
});
|
||||
|
||||
createPixelPlacer({
|
||||
canvas: canvasEl,
|
||||
renderer,
|
||||
getColor: palette.getColor,
|
||||
getState: () => canvasState,
|
||||
socket,
|
||||
bus.on("pixel-placed", (update) => {
|
||||
socket.emit("place-pixel", update);
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
Reference in New Issue
Block a user