Compare commits

..

3 Commits

Author SHA1 Message Date
99f614b7b7 Add UserId newtype and WebSocket ping keep-alive
All checks were successful
CI / ci (push) Successful in 5m57s
Replace raw String user IDs with a UserId newtype in domain for
type safety, and add 30s ping/pong to prevent idle WebSocket
disconnects.
2026-08-18 17:02:19 +02:00
4db04329b4 Format code for better readability in main.js and style.css
All checks were successful
CI / ci (push) Successful in 6m22s
2026-08-18 10:54:42 +02:00
dc6bff027a 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).
2026-08-18 10:49:16 +02:00
24 changed files with 583 additions and 199 deletions

View File

@@ -3,7 +3,7 @@ use std::sync::Arc;
use api_types::{self, PixelUpdatePayload};
use application::AppState;
use application::canvas::place_pixel;
use domain::{BroadcastEvent, BroadcastSubscription, Color, Position};
use domain::{BroadcastEvent, BroadcastSubscription, Color, Position, UserId};
use socketioxide::extract::{Data, SocketRef};
use tracing::info;
@@ -28,8 +28,8 @@ fn send_canvas_state(socket: &SocketRef, state: &AppState) {
}
fn register_soldier(state: &AppState, socket: &SocketRef) {
let socket_id = socket.id.to_string();
application::soldiers::connect::execute(state, socket_id);
let user_id = UserId::new(socket.id.to_string());
application::soldiers::connect::execute(state, user_id);
}
fn spawn_broadcast_forwarder(socket: SocketRef, mut subscription: BroadcastSubscription) {
@@ -71,9 +71,9 @@ fn handle_place_pixel(socket: &SocketRef, state: &AppState, payload: PixelUpdate
info!("Received pixel update: {position} color={}", color.as_u32());
let socket_id = socket.id.to_string();
let user_id = UserId::new(socket.id.to_string());
let command = place_pixel::Command {
user_id: &socket_id,
user_id: &user_id,
position,
color,
};
@@ -97,8 +97,8 @@ fn register_disconnect_handler(socket: &SocketRef, state: Arc<AppState>) {
fn handle_disconnect(socket: &SocketRef, state: &AppState) {
info!("Socket disconnected: {:?}", socket.id);
let socket_id = socket.id.to_string();
application::soldiers::disconnect::execute(state, &socket_id);
let user_id = UserId::new(socket.id.to_string());
application::soldiers::disconnect::execute(state, &user_id);
}
fn emit_error(socket: &SocketRef, message: &str) {

View File

@@ -1,11 +1,12 @@
use std::io::Write;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::Duration;
use axum::extract::State;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::response::IntoResponse;
use domain::{BroadcastEvent, BroadcastSubscription, Color, Position};
use domain::{BroadcastEvent, BroadcastSubscription, Color, Position, UserId};
use flate2::Compression;
use flate2::write::GzEncoder;
use futures::{SinkExt, StreamExt, stream::SplitSink};
@@ -29,10 +30,12 @@ pub async fn ws_upgrade(
async fn handle_connection(socket: WebSocket, state: Arc<WsState>) {
let (mut sender, mut receiver) = socket.split();
let connection_id = state
let connection_id = UserId::new(
state
.connection_counter
.fetch_add(1, Ordering::Relaxed)
.to_string();
.to_string(),
);
info!("WebSocket connected: {connection_id}");
@@ -105,11 +108,16 @@ fn gzip_compress(data: &[u8]) -> Option<Vec<u8>> {
encoder.finish().ok()
}
const PING_INTERVAL: Duration = Duration::from_secs(30);
async fn run_send_loop(
mut sender: WsSender,
mut subscription: BroadcastSubscription,
mut error_receiver: mpsc::UnboundedReceiver<String>,
) {
let mut ping_interval = tokio::time::interval(PING_INTERVAL);
ping_interval.tick().await;
loop {
tokio::select! {
Some(event) = subscription.recv() => {
@@ -123,6 +131,11 @@ async fn run_send_loop(
break;
}
}
_ = ping_interval.tick() => {
if sender.send(Message::Ping(Vec::new().into())).await.is_err() {
break;
}
}
else => break,
}
}
@@ -143,7 +156,7 @@ fn serialize_broadcast_event(event: &BroadcastEvent) -> Option<String> {
fn handle_client_message(
state: &AppState,
error_sender: &mpsc::UnboundedSender<String>,
connection_id: &str,
connection_id: &UserId,
text: &str,
) {
let Ok(message) = serde_json::from_str::<ClientMessage>(text) else {
@@ -160,7 +173,7 @@ fn handle_client_message(
fn handle_place_pixel(
state: &AppState,
error_sender: &mpsc::UnboundedSender<String>,
connection_id: &str,
connection_id: &UserId,
x: u32,
y: u32,
color: u32,

View File

@@ -1,9 +1,9 @@
use domain::{BroadcastEvent, Color, PixelUpdate, Position};
use domain::{BroadcastEvent, Color, PixelUpdate, Position, UserId};
use crate::{AppState, ApplicationError};
pub struct Command<'a> {
pub user_id: &'a str,
pub user_id: &'a UserId,
pub position: Position,
pub color: Color,
}

View File

@@ -1,8 +1,8 @@
use domain::BroadcastEvent;
use domain::{BroadcastEvent, UserId};
use crate::AppState;
pub fn execute(state: &AppState, user_id: String) -> usize {
pub fn execute(state: &AppState, user_id: UserId) -> usize {
let count = state.soldiers().add(user_id);
state
.broadcaster()

View File

@@ -1,8 +1,8 @@
use domain::BroadcastEvent;
use domain::{BroadcastEvent, UserId};
use crate::AppState;
pub fn execute(state: &AppState, user_id: &str) -> usize {
pub fn execute(state: &AppState, user_id: &UserId) -> usize {
state.cooldowns().remove(user_id);
let count = state.soldiers().remove(user_id);
state

View File

@@ -7,7 +7,7 @@ use std::time::{Duration, Instant};
use domain::ports::{
BroadcastReceiverInner, BroadcastSubscription, CanvasPersistence, CanvasStore, EventBroadcaster,
};
use domain::{BroadcastEvent, Canvas, Color, DomainError, Position};
use domain::{BroadcastEvent, Canvas, Color, DomainError, Position, UserId};
use tokio::sync::broadcast;
use tracing::warn;
@@ -145,7 +145,7 @@ impl CanvasStore for InMemoryCanvasStore {
}
pub struct CooldownTracker {
entries: Mutex<HashMap<String, Instant>>,
entries: Mutex<HashMap<UserId, Instant>>,
cooldown: Duration,
}
@@ -157,7 +157,7 @@ impl CooldownTracker {
}
}
pub fn is_on_cooldown(&self, user_id: &str) -> bool {
pub fn is_on_cooldown(&self, user_id: &UserId) -> bool {
let entries = acquire_lock(&self.entries);
entries
.get(user_id)
@@ -165,17 +165,17 @@ impl CooldownTracker {
.unwrap_or(false)
}
pub fn record(&self, user_id: &str) {
acquire_lock(&self.entries).insert(user_id.to_string(), Instant::now());
pub fn record(&self, user_id: &UserId) {
acquire_lock(&self.entries).insert(user_id.clone(), Instant::now());
}
pub fn remove(&self, user_id: &str) {
pub fn remove(&self, user_id: &UserId) {
acquire_lock(&self.entries).remove(user_id);
}
}
pub struct SoldierTracker {
connected: Mutex<HashSet<String>>,
connected: Mutex<HashSet<UserId>>,
}
impl SoldierTracker {
@@ -185,13 +185,13 @@ impl SoldierTracker {
}
}
pub fn add(&self, user_id: String) -> usize {
pub fn add(&self, user_id: UserId) -> usize {
let mut connected = acquire_lock(&self.connected);
connected.insert(user_id);
connected.len()
}
pub fn remove(&self, user_id: &str) -> usize {
pub fn remove(&self, user_id: &UserId) -> usize {
let mut connected = acquire_lock(&self.connected);
connected.remove(user_id);
connected.len()

View File

@@ -1,14 +1,18 @@
mod common;
use application::canvas::place_pixel::{Command, Outcome};
use domain::{BroadcastEvent, Color, Position};
use domain::{BroadcastEvent, Color, Position, UserId};
fn uid(id: &str) -> UserId {
UserId::new(id.to_string())
}
macro_rules! place {
($state:expr, $user:expr, $x:expr, $y:expr, $color:expr) => {
application::canvas::place_pixel::execute(
&$state,
Command {
user_id: $user,
user_id: &uid($user),
position: Position::new($x, $y),
color: Color::new($color),
},

View File

@@ -3,7 +3,7 @@ mod common;
use std::sync::Arc;
use application::AppState;
use domain::Color;
use domain::{Color, UserId};
fn state_with_persistence(persistence: common::FakePersistence) -> Arc<AppState> {
let spy = common::SpyBroadcaster::new();
@@ -22,10 +22,11 @@ fn save_snapshot_persists_current_canvas() {
let saved_ref = persistence.saved_ref();
let state = state_with_persistence(persistence);
let user = UserId::new("user".to_string());
application::canvas::place_pixel::execute(
&state,
application::canvas::place_pixel::Command {
user_id: "user",
user_id: &user,
position: domain::Position::new(0, 0),
color: Color::new(0xFF),
},

View File

@@ -1,40 +1,41 @@
mod common;
use domain::BroadcastEvent;
use domain::{BroadcastEvent, UserId};
fn uid(id: &str) -> UserId {
UserId::new(id.to_string())
}
#[test]
fn connect_increments_count() {
let (state, _) = common::test_state();
assert_eq!(
application::soldiers::connect::execute(&state, "a".into()),
1
);
assert_eq!(
application::soldiers::connect::execute(&state, "b".into()),
2
);
assert_eq!(
application::soldiers::connect::execute(&state, "c".into()),
3
);
assert_eq!(application::soldiers::connect::execute(&state, uid("a")), 1);
assert_eq!(application::soldiers::connect::execute(&state, uid("b")), 2);
assert_eq!(application::soldiers::connect::execute(&state, uid("c")), 3);
}
#[test]
fn disconnect_decrements_count() {
let (state, _) = common::test_state();
application::soldiers::connect::execute(&state, "a".into());
application::soldiers::connect::execute(&state, "b".into());
application::soldiers::connect::execute(&state, uid("a"));
application::soldiers::connect::execute(&state, uid("b"));
assert_eq!(application::soldiers::disconnect::execute(&state, "a"), 1);
assert_eq!(application::soldiers::disconnect::execute(&state, "b"), 0);
assert_eq!(
application::soldiers::disconnect::execute(&state, &uid("a")),
1
);
assert_eq!(
application::soldiers::disconnect::execute(&state, &uid("b")),
0
);
}
#[test]
fn disconnect_unknown_user_is_harmless() {
let (state, _) = common::test_state();
application::soldiers::connect::execute(&state, "a".into());
application::soldiers::connect::execute(&state, uid("a"));
assert_eq!(
application::soldiers::disconnect::execute(&state, "unknown"),
application::soldiers::disconnect::execute(&state, &uid("unknown")),
1
);
}
@@ -42,7 +43,7 @@ fn disconnect_unknown_user_is_harmless() {
#[test]
fn connect_publishes_soldier_count() {
let (state, spy) = common::test_state();
application::soldiers::connect::execute(&state, "a".into());
application::soldiers::connect::execute(&state, uid("a"));
let events = spy.events();
assert_eq!(events.len(), 1);
@@ -52,8 +53,8 @@ fn connect_publishes_soldier_count() {
#[test]
fn disconnect_publishes_soldier_count() {
let (state, spy) = common::test_state();
application::soldiers::connect::execute(&state, "a".into());
application::soldiers::disconnect::execute(&state, "a");
application::soldiers::connect::execute(&state, uid("a"));
application::soldiers::disconnect::execute(&state, &uid("a"));
let events = spy.events();
assert_eq!(events.len(), 2);
@@ -63,26 +64,27 @@ fn disconnect_publishes_soldier_count() {
#[test]
fn disconnect_clears_cooldown() {
let (state, _) = common::test_state();
application::soldiers::connect::execute(&state, "user-1".into());
let user = uid("user-1");
application::soldiers::connect::execute(&state, user.clone());
application::canvas::place_pixel::execute(
&state,
application::canvas::place_pixel::Command {
user_id: "user-1",
user_id: &user,
position: domain::Position::new(0, 0),
color: domain::Color::new(0xFF),
},
)
.unwrap();
application::soldiers::disconnect::execute(&state, "user-1");
application::soldiers::disconnect::execute(&state, &user);
// Reconnect with same ID — cooldown should be gone
application::soldiers::connect::execute(&state, "user-1".into());
application::soldiers::connect::execute(&state, user.clone());
let result = application::canvas::place_pixel::execute(
&state,
application::canvas::place_pixel::Command {
user_id: "user-1",
user_id: &user,
position: domain::Position::new(1, 1),
color: domain::Color::new(0xAA),
},

View File

@@ -8,6 +8,6 @@ pub use canvas::Canvas;
pub use errors::DomainError;
pub use events::BroadcastEvent;
pub use ports::BroadcastSubscription;
pub use value_objects::{Color, PixelUpdate, Position};
pub use value_objects::{Color, PixelUpdate, Position, UserId};
pub const COOLDOWN_MESSAGE: &str = "You can only place one pixel per minute";

View File

@@ -1,5 +1,24 @@
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct UserId(String);
impl UserId {
pub fn new(id: String) -> Self {
Self(id)
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for UserId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(transparent)]
pub struct Color(u32);

View File

@@ -85,6 +85,21 @@
</p>
<div id="countdown" class="z-20 text-xs">You can place a pixel now</div>
<div class="z-20 flex items-center gap-2 mx-1">
<button
id="mode-paint"
class="flex items-center justify-center w-8 h-8 rounded shadow bg-slate-200 hover:bg-slate-300 ring-2 ring-offset-2 ring-cyan-400"
title="Paint mode"
>
<svg viewBox="0 0 16 16" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5"><line x1="8" y1="1" x2="8" y2="6"/><line x1="8" y1="10" x2="8" y2="15"/><line x1="1" y1="8" x2="6" y2="8"/><line x1="10" y1="8" x2="15" y2="8"/></svg>
</button>
<button
id="mode-hand"
class="flex items-center justify-center w-8 h-8 rounded shadow bg-slate-200 hover:bg-slate-300"
title="Move mode (hold Space)"
>
<svg viewBox="0 0 16 16" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5"><line x1="8" y1="1" x2="8" y2="15"/><line x1="1" y1="8" x2="15" y2="8"/><polyline points="5,3 8,1 11,3"/><polyline points="5,13 8,15 11,13"/><polyline points="3,5 1,8 3,11"/><polyline points="13,5 15,8 13,11"/></svg>
</button>
<div class="w-px h-6 bg-slate-300"></div>
<button
id="zoom-out"
class="w-8 h-8 text-lg font-bold rounded shadow bg-slate-200 hover:bg-slate-300"

View File

@@ -0,0 +1,20 @@
export const createAppState = (initial) => {
const state = { ...initial };
const listeners = {};
return {
get: (key) => state[key],
set: (key, value) => {
const old = state[key];
state[key] = value;
(listeners[key] || []).forEach((fn) => fn(value, old));
},
subscribe: (key, fn) => {
if (!listeners[key]) listeners[key] = [];
listeners[key].push(fn);
return () => {
listeners[key] = listeners[key].filter((f) => f !== fn);
};
},
};
};

View File

@@ -0,0 +1,17 @@
export const createEventBus = () => {
const handlers = {};
return {
on: (event, fn) => {
if (!handlers[event]) handlers[event] = [];
handlers[event].push(fn);
},
off: (event, fn) => {
if (!handlers[event]) return;
handlers[event] = handlers[event].filter((f) => f !== fn);
},
emit: (event, ...args) => {
(handlers[event] || []).forEach((fn) => fn(...args));
},
};
};

View File

@@ -0,0 +1,51 @@
export const createKeyboard = () => {
const actions = {};
const handlers = {};
const heldKeys = new Set();
const dispatch = (actionName) => {
(handlers[actionName] || []).forEach((fn) => fn());
};
window.addEventListener("keydown", (event) => {
const action = actions[event.key];
if (!action) return;
event.preventDefault();
if (action.hold) {
if (!event.repeat && !heldKeys.has(event.key)) {
heldKeys.add(event.key);
dispatch(`${action.name}:down`);
}
} else if (!event.repeat) {
dispatch(action.name);
}
});
window.addEventListener("keyup", (event) => {
const action = actions[event.key];
if (!action) return;
event.preventDefault();
if (action.hold && heldKeys.has(event.key)) {
heldKeys.delete(event.key);
dispatch(`${action.name}:up`);
}
});
window.addEventListener("blur", () => {
for (const key of heldKeys) {
const action = actions[key];
if (action?.hold) dispatch(`${action.name}:up`);
}
heldKeys.clear();
});
return {
register: (key, name, opts = {}) => {
actions[key] = { name, ...opts };
},
onAction: (actionName, fn) => {
if (!handlers[actionName]) handlers[actionName] = [];
handlers[actionName].push(fn);
},
};
};

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

View File

@@ -16,3 +16,23 @@
image-rendering: crisp-edges;
display: block;
}
#current-soldiers {
display: inline-block;
}
@keyframes soldier-pop {
0% {
transform: scale(1);
}
50% {
transform: scale(1.4);
}
100% {
transform: scale(1);
}
}
.soldier-pop {
animation: soldier-pop 0.3s ease-out;
}

View File

@@ -0,0 +1,34 @@
export const createHandTool = ({ viewport }) => {
let isDragging = false;
let dragStartX = 0;
let dragStartY = 0;
let scrollStartLeft = 0;
let scrollStartTop = 0;
return {
name: "hand",
cursor: "grab",
onMouseDown: (event) => {
isDragging = true;
dragStartX = event.clientX;
dragStartY = event.clientY;
scrollStartLeft = viewport.scrollLeft;
scrollStartTop = viewport.scrollTop;
viewport.style.cursor = "grabbing";
event.preventDefault();
},
onMouseMove: (event) => {
if (!isDragging) return;
viewport.scrollLeft = scrollStartLeft - (event.clientX - dragStartX);
viewport.scrollTop = scrollStartTop - (event.clientY - dragStartY);
},
onMouseUp: () => {
if (!isDragging) return;
isDragging = false;
viewport.style.cursor = "grab";
},
onDeactivate: () => {
isDragging = false;
},
};
};

View File

@@ -0,0 +1,46 @@
import { hexToU32 } from "../domain/color.js";
import { canPlacePixel } from "../domain/cooldown.js";
import { setPixel } from "../domain/canvas-state.js";
import { getCanvasCoords } from "../domain/coords.js";
export const createPaintTool = ({ canvas, renderer, appState, bus }) => {
let previewPixel = null;
const clearPreview = () => {
if (!previewPixel) return;
renderer.restorePixel(
appState.get("canvasPixels"),
previewPixel.x,
previewPixel.y,
);
previewPixel = null;
};
return {
name: "paint",
cursor: "crosshair",
onClick: (event) => {
const { x, y } = getCanvasCoords(event, canvas);
const color = hexToU32(appState.get("selectedColor"));
clearPreview();
previewPixel = { x, y, color };
renderer.drawPreview(x, y, color);
},
confirm: () => {
if (!previewPixel) return;
if (!canPlacePixel(appState.get("lastPlacementTime"))) return;
bus.emit("pixel-placed", previewPixel);
setPixel(
appState.get("canvasPixels"),
previewPixel.x,
previewPixel.y,
previewPixel.color,
);
appState.set("lastPlacementTime", Date.now());
clearPreview();
},
cancel: () => clearPreview(),
onDeactivate: () => clearPreview(),
};
};

View File

@@ -1,9 +1,13 @@
const MIN_ZOOM = 1;
const MAX_ZOOM = 40;
export const createCanvasViewport = (canvas) => {
const viewport = canvas.parentElement;
const zoomLabel = document.getElementById("zoom-level");
export const createCanvasViewport = ({
canvas,
viewport,
zoomIn,
zoomOut,
zoomLabel,
}) => {
let zoom = 1;
let baseSize = viewport.clientWidth;
@@ -59,12 +63,12 @@ export const createCanvasViewport = (canvas) => {
lastPinchDist = 0;
});
document.getElementById("zoom-in")?.addEventListener("click", () => {
zoomIn?.addEventListener("click", () => {
setZoom(zoom * 1.5);
applyZoom();
});
document.getElementById("zoom-out")?.addEventListener("click", () => {
zoomOut?.addEventListener("click", () => {
setZoom(zoom / 1.5);
applyZoom();
});

View File

@@ -14,20 +14,17 @@ const PALETTE_IDS = [
"brown",
];
export const createColorPalette = () => {
const colorPicker = document.getElementById("color-picker");
const currentColorSpan = document.getElementById("current-color-span");
export const createColorPalette = ({ colorPicker, colorSpan, appState }) => {
let activeButton = null;
let currentColor = localStorage.getItem("currentColor") || "#000000";
colorPicker.value = currentColor;
currentColorSpan.style.backgroundColor = currentColor;
const color = appState.get("selectedColor");
colorPicker.value = color;
colorSpan.style.backgroundColor = color;
const setColor = (hex, button) => {
currentColor = hex;
currentColorSpan.textContent = hex;
currentColorSpan.style.backgroundColor = hex;
localStorage.setItem("currentColor", hex);
appState.set("selectedColor", hex);
colorSpan.textContent = hex;
colorSpan.style.backgroundColor = hex;
if (activeButton)
activeButton.classList.remove("ring-2", "ring-offset-2", "ring-cyan-400");
@@ -48,6 +45,4 @@ export const createColorPalette = () => {
);
});
}
return { getColor: () => currentColor };
};

View File

@@ -1,16 +1,51 @@
import { timeRemaining } from "../domain/cooldown.js";
export const startCooldownDisplay = () => {
const countdownDiv = document.getElementById("countdown");
let audioCtx = null;
setInterval(() => {
const lastPlacementTime = parseInt(
localStorage.getItem("lastPixelTime") || "0",
);
const playBeep = () => {
try {
if (!audioCtx) audioCtx = new AudioContext();
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.type = "sine";
osc.frequency.setValueAtTime(523, audioCtx.currentTime);
osc.frequency.setValueAtTime(659, audioCtx.currentTime + 0.1);
gain.gain.setValueAtTime(0.2, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.25);
osc.start();
osc.stop(audioCtx.currentTime + 0.25);
} catch (e) {}
};
export const startCooldownDisplay = ({ countdown, placeBtn, appState }) => {
let wasCoolingDown = false;
const update = () => {
const lastPlacementTime = appState.get("lastPlacementTime");
const remaining = timeRemaining(lastPlacementTime);
countdownDiv.textContent =
remaining > 0
const isCoolingDown = remaining > 0;
countdown.textContent = isCoolingDown
? `You can place a pixel in ${Math.ceil(remaining / 1000)} seconds`
: "You can place a pixel now";
}, 1000);
placeBtn.disabled = isCoolingDown;
if (isCoolingDown) {
placeBtn.classList.add("opacity-50", "cursor-not-allowed");
} else {
placeBtn.classList.remove("opacity-50", "cursor-not-allowed");
}
if (wasCoolingDown && !isCoolingDown && lastPlacementTime > 0) {
playBeep();
}
wasCoolingDown = isCoolingDown;
};
update();
setInterval(update, 1000);
appState.subscribe("lastPlacementTime", update);
};

View File

@@ -1,57 +0,0 @@
import { hexToU32 } from "../domain/color.js";
import { canPlacePixel } from "../domain/cooldown.js";
import { setPixel } from "../domain/canvas-state.js";
import { getCanvasCoords } from "../domain/coords.js";
export const createPixelPlacer = ({
canvas,
renderer,
getColor,
getState,
socket,
}) => {
let previewPixel = null;
const place = (update) => {
const lastPixelTime = parseInt(
localStorage.getItem("lastPixelTime") || "0",
);
if (!canPlacePixel(lastPixelTime)) return;
socket.emit("place-pixel", update);
setPixel(getState(), update.x, update.y, update.color);
localStorage.setItem("lastPixelTime", Date.now().toString());
};
const clearPreview = () => {
if (!previewPixel) return;
renderer.restorePixel(getState(), previewPixel.x, previewPixel.y);
previewPixel = null;
};
const confirmPlacement = () => {
if (!previewPixel) return;
place(previewPixel);
clearPreview();
};
const selectPixel = (event) => {
const { x, y } = getCanvasCoords(event, canvas);
const color = hexToU32(getColor());
clearPreview();
previewPixel = { x, y, color };
renderer.drawPreview(x, y, color);
};
canvas.addEventListener("click", selectPixel);
window.addEventListener("keydown", (event) => {
if (event.key === "Enter") confirmPlacement();
if (event.key === "Escape") clearPreview();
});
document
.getElementById("place-pixel")
.addEventListener("click", confirmPlacement);
};

View File

@@ -0,0 +1,65 @@
export const createToolManager = ({ canvas, viewport, onActiveChange }) => {
const tools = {};
let activeName = null;
let heldName = null;
let previousName = null;
const setActive = (name) => {
if (activeName === name) return;
const prev = tools[activeName];
if (prev?.onDeactivate) prev.onDeactivate();
activeName = name;
const tool = tools[activeName];
viewport.style.cursor = tool.cursor || "default";
if (tool.onActivate) tool.onActivate();
if (onActiveChange) onActiveChange(name);
};
canvas.addEventListener("click", (event) => {
const tool = tools[activeName];
if (tool?.onClick) tool.onClick(event);
});
viewport.addEventListener("mousedown", (event) => {
const tool = tools[activeName];
if (tool?.onMouseDown) tool.onMouseDown(event);
});
window.addEventListener("mousemove", (event) => {
const tool = tools[activeName];
if (tool?.onMouseMove) tool.onMouseMove(event);
});
window.addEventListener("mouseup", (event) => {
const tool = tools[activeName];
if (tool?.onMouseUp) tool.onMouseUp(event);
});
return {
register: (tool) => {
tools[tool.name] = tool;
},
setActive,
getActive: () => activeName,
holdTool: (name) => {
if (heldName) return;
previousName = activeName;
heldName = name;
setActive(name);
},
releaseTool: () => {
if (!heldName) return;
heldName = null;
setActive(previousName);
previousName = null;
},
confirm: () => {
const tool = tools[activeName];
if (tool?.confirm) tool.confirm();
},
cancel: () => {
const tool = tools[activeName];
if (tool?.cancel) tool.cancel();
},
};
};