Add UI improvements + refactor frontend to event-driven architecture
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:
2026-08-18 10:49:16 +02:00
parent 3fbae1bd95
commit dc6bff027a
13 changed files with 475 additions and 138 deletions

View File

@@ -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(() => {