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

View File

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

View File

@@ -1,8 +1,8 @@
use domain::BroadcastEvent; use domain::{BroadcastEvent, UserId};
use crate::AppState; 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); let count = state.soldiers().add(user_id);
state state
.broadcaster() .broadcaster()

View File

@@ -1,8 +1,8 @@
use domain::BroadcastEvent; use domain::{BroadcastEvent, UserId};
use crate::AppState; 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); state.cooldowns().remove(user_id);
let count = state.soldiers().remove(user_id); let count = state.soldiers().remove(user_id);
state state

View File

@@ -7,7 +7,7 @@ use std::time::{Duration, Instant};
use domain::ports::{ use domain::ports::{
BroadcastReceiverInner, BroadcastSubscription, CanvasPersistence, CanvasStore, EventBroadcaster, 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 tokio::sync::broadcast;
use tracing::warn; use tracing::warn;
@@ -145,7 +145,7 @@ impl CanvasStore for InMemoryCanvasStore {
} }
pub struct CooldownTracker { pub struct CooldownTracker {
entries: Mutex<HashMap<String, Instant>>, entries: Mutex<HashMap<UserId, Instant>>,
cooldown: Duration, 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); let entries = acquire_lock(&self.entries);
entries entries
.get(user_id) .get(user_id)
@@ -165,17 +165,17 @@ impl CooldownTracker {
.unwrap_or(false) .unwrap_or(false)
} }
pub fn record(&self, user_id: &str) { pub fn record(&self, user_id: &UserId) {
acquire_lock(&self.entries).insert(user_id.to_string(), Instant::now()); 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); acquire_lock(&self.entries).remove(user_id);
} }
} }
pub struct SoldierTracker { pub struct SoldierTracker {
connected: Mutex<HashSet<String>>, connected: Mutex<HashSet<UserId>>,
} }
impl SoldierTracker { 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); let mut connected = acquire_lock(&self.connected);
connected.insert(user_id); connected.insert(user_id);
connected.len() 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); let mut connected = acquire_lock(&self.connected);
connected.remove(user_id); connected.remove(user_id);
connected.len() connected.len()

View File

@@ -1,14 +1,18 @@
mod common; mod common;
use application::canvas::place_pixel::{Command, Outcome}; 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 { macro_rules! place {
($state:expr, $user:expr, $x:expr, $y:expr, $color:expr) => { ($state:expr, $user:expr, $x:expr, $y:expr, $color:expr) => {
application::canvas::place_pixel::execute( application::canvas::place_pixel::execute(
&$state, &$state,
Command { Command {
user_id: $user, user_id: &uid($user),
position: Position::new($x, $y), position: Position::new($x, $y),
color: Color::new($color), color: Color::new($color),
}, },

View File

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

View File

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

View File

@@ -8,6 +8,6 @@ pub use canvas::Canvas;
pub use errors::DomainError; pub use errors::DomainError;
pub use events::BroadcastEvent; pub use events::BroadcastEvent;
pub use ports::BroadcastSubscription; 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"; pub const COOLDOWN_MESSAGE: &str = "You can only place one pixel per minute";

View File

@@ -1,5 +1,24 @@
use std::fmt; 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(transparent)] #[repr(transparent)]
pub struct Color(u32); pub struct Color(u32);

View File

@@ -85,6 +85,21 @@
</p> </p>
<div id="countdown" class="z-20 text-xs">You can place a pixel now</div> <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"> <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 <button
id="zoom-out" id="zoom-out"
class="w-8 h-8 text-lg font-bold rounded shadow bg-slate-200 hover:bg-slate-300" 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 { createSocketConnection } from "./infrastructure/socket-client.js";
import { checkServer } from "./infrastructure/api.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 { createCanvasRenderer } from "./ui/canvas-renderer.js";
import { createColorPalette } from "./ui/color-palette.js"; import { createColorPalette } from "./ui/color-palette.js";
import { createPixelPlacer } from "./ui/pixel-placer.js";
import { startCooldownDisplay } from "./ui/cooldown-display.js"; import { startCooldownDisplay } from "./ui/cooldown-display.js";
import { createCanvasViewport } from "./ui/canvas-viewport.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 { setPixel } from "./domain/canvas-state.js";
import { getCanvasCoords } from "./domain/coords.js"; import { getCanvasCoords } from "./domain/coords.js";
const canvasEl = document.getElementById("canvas"); const els = {
const coordsText = document.getElementById("coords"); canvas: document.getElementById("canvas"),
const currentSoldiersSpan = document.getElementById("current-soldiers"); viewport: document.getElementById("canvas-viewport"),
const statusEl = document.getElementById("connection-status"); 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; const savedDisplay = els.canvas.style.display;
canvasEl.style.display = "none"; els.canvas.style.display = "none";
let canvasState = []; const appState = createAppState({
canvasPixels: [],
const renderer = createCanvasRenderer(canvasEl); lastPlacementTime: parseInt(localStorage.getItem("lastPixelTime") || "0"),
const palette = createColorPalette(); selectedColor: localStorage.getItem("currentColor") || "#000000",
soldierCount: 0,
startCooldownDisplay();
createCanvasViewport(canvasEl);
canvasEl.addEventListener("mousemove", (event) => {
const { x, y } = getCanvasCoords(event, canvasEl);
coordsText.textContent = `${x}, ${y}`;
}); });
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"); const a = document.createElement("a");
a.href = renderer.toDataURL(); a.href = renderer.toDataURL();
a.download = "canvas.png"; a.download = "canvas.png";
@@ -37,50 +120,67 @@ document.getElementById("save-canvas").addEventListener("click", () => {
}); });
const showStatus = (message, isError) => { const showStatus = (message, isError) => {
if (!statusEl) return; els.status.textContent = message;
statusEl.textContent = message; els.status.className = isError
statusEl.className = isError
? "text-red-500 text-sm" ? "text-red-500 text-sm"
: "text-green-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() checkServer()
.then((response) => { .then((response) => {
if (!response.ok) throw new Error("Server unavailable"); if (!response.ok) throw new Error("Server unavailable");
const socket = createSocketConnection(); const socket = createSocketConnection();
socket.on("connect", () => { socket.on("connect", () => bus.emit("connect"));
canvasEl.style.display = savedDisplay; socket.on("canvas_state", (data) => bus.emit("canvas_state", data));
showStatus("Connected", false); 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) => { bus.on("pixel-placed", (update) => {
canvasState = data; socket.emit("place-pixel", update);
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,
}); });
}) })
.catch(() => { .catch(() => {

View File

@@ -16,3 +16,23 @@
image-rendering: crisp-edges; image-rendering: crisp-edges;
display: block; 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 MIN_ZOOM = 1;
const MAX_ZOOM = 40; const MAX_ZOOM = 40;
export const createCanvasViewport = (canvas) => { export const createCanvasViewport = ({
const viewport = canvas.parentElement; canvas,
const zoomLabel = document.getElementById("zoom-level"); viewport,
zoomIn,
zoomOut,
zoomLabel,
}) => {
let zoom = 1; let zoom = 1;
let baseSize = viewport.clientWidth; let baseSize = viewport.clientWidth;
@@ -59,12 +63,12 @@ export const createCanvasViewport = (canvas) => {
lastPinchDist = 0; lastPinchDist = 0;
}); });
document.getElementById("zoom-in")?.addEventListener("click", () => { zoomIn?.addEventListener("click", () => {
setZoom(zoom * 1.5); setZoom(zoom * 1.5);
applyZoom(); applyZoom();
}); });
document.getElementById("zoom-out")?.addEventListener("click", () => { zoomOut?.addEventListener("click", () => {
setZoom(zoom / 1.5); setZoom(zoom / 1.5);
applyZoom(); applyZoom();
}); });

View File

@@ -14,20 +14,17 @@ const PALETTE_IDS = [
"brown", "brown",
]; ];
export const createColorPalette = () => { export const createColorPalette = ({ colorPicker, colorSpan, appState }) => {
const colorPicker = document.getElementById("color-picker");
const currentColorSpan = document.getElementById("current-color-span");
let activeButton = null; let activeButton = null;
let currentColor = localStorage.getItem("currentColor") || "#000000"; const color = appState.get("selectedColor");
colorPicker.value = currentColor; colorPicker.value = color;
currentColorSpan.style.backgroundColor = currentColor; colorSpan.style.backgroundColor = color;
const setColor = (hex, button) => { const setColor = (hex, button) => {
currentColor = hex; appState.set("selectedColor", hex);
currentColorSpan.textContent = hex; colorSpan.textContent = hex;
currentColorSpan.style.backgroundColor = hex; colorSpan.style.backgroundColor = hex;
localStorage.setItem("currentColor", hex);
if (activeButton) if (activeButton)
activeButton.classList.remove("ring-2", "ring-offset-2", "ring-cyan-400"); 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"; import { timeRemaining } from "../domain/cooldown.js";
export const startCooldownDisplay = () => { let audioCtx = null;
const countdownDiv = document.getElementById("countdown");
setInterval(() => { const playBeep = () => {
const lastPlacementTime = parseInt( try {
localStorage.getItem("lastPixelTime") || "0", if (!audioCtx) audioCtx = new AudioContext();
); const osc = audioCtx.createOscillator();
const remaining = timeRemaining(lastPlacementTime); const gain = audioCtx.createGain();
countdownDiv.textContent = osc.connect(gain);
remaining > 0 gain.connect(audioCtx.destination);
? `You can place a pixel in ${Math.ceil(remaining / 1000)} seconds` osc.type = "sine";
: "You can place a pixel now"; osc.frequency.setValueAtTime(523, audioCtx.currentTime);
}, 1000); 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);
const isCoolingDown = remaining > 0;
countdown.textContent = isCoolingDown
? `You can place a pixel in ${Math.ceil(remaining / 1000)} seconds`
: "You can place a pixel now";
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();
},
};
};