Compare commits
5 Commits
f652785acb
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 99f614b7b7 | |||
| 4db04329b4 | |||
| dc6bff027a | |||
| 3fbae1bd95 | |||
| 31f0fce7a9 |
62
Cargo.lock
generated
62
Cargo.lock
generated
@@ -2,6 +2,12 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "adler2"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "allocator-api2"
|
name = "allocator-api2"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@@ -10,7 +16,7 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "api-types"
|
name = "api-types"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"domain",
|
"domain",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -18,7 +24,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "application"
|
name = "application"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"domain",
|
"domain",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
@@ -160,7 +166,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "canvas-file"
|
name = "canvas-file"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"config",
|
"config",
|
||||||
"domain",
|
"domain",
|
||||||
@@ -186,14 +192,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "config"
|
name = "config"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "config-env"
|
name = "config-env"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"config",
|
"config",
|
||||||
]
|
]
|
||||||
@@ -222,6 +228,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc32fast"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-utils"
|
name = "crossbeam-utils"
|
||||||
version = "0.8.19"
|
version = "0.8.19"
|
||||||
@@ -322,7 +337,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "domain"
|
name = "domain"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
]
|
]
|
||||||
@@ -385,6 +400,16 @@ version = "1.0.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flate2"
|
||||||
|
version = "1.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"miniz_oxide",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fnv"
|
name = "fnv"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
@@ -634,7 +659,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http-axum"
|
name = "http-axum"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"config",
|
"config",
|
||||||
@@ -841,6 +866,16 @@ dependencies = [
|
|||||||
"unicase",
|
"unicase",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "miniz_oxide"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||||
|
dependencies = [
|
||||||
|
"adler2",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@@ -1208,7 +1243,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "server"
|
name = "server"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"application",
|
"application",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -1275,6 +1310,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd-adler32"
|
||||||
|
version = "0.3.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.9"
|
version = "0.4.9"
|
||||||
@@ -1305,7 +1346,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "socketio"
|
name = "socketio"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"api-types",
|
"api-types",
|
||||||
"application",
|
"application",
|
||||||
@@ -1823,11 +1864,12 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "websocket"
|
name = "websocket"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"application",
|
"application",
|
||||||
"axum",
|
"axum",
|
||||||
"domain",
|
"domain",
|
||||||
|
"flate2",
|
||||||
"futures",
|
"futures",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ default-members = [
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
@@ -37,6 +37,7 @@ config-env = { path = "crates/adapters/config-env" }
|
|||||||
http-axum = { path = "crates/adapters/http-axum" }
|
http-axum = { path = "crates/adapters/http-axum" }
|
||||||
socketio = { path = "crates/adapters/socketio" }
|
socketio = { path = "crates/adapters/socketio" }
|
||||||
websocket = { path = "crates/adapters/websocket" }
|
websocket = { path = "crates/adapters/websocket" }
|
||||||
|
flate2 = "1"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
|
|
||||||
axum = "0.8.9"
|
axum = "0.8.9"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ WORKDIR /app/painter-js
|
|||||||
COPY painter-js/package.json painter-js/bun.lock ./
|
COPY painter-js/package.json painter-js/bun.lock ./
|
||||||
RUN bun install --frozen-lockfile
|
RUN bun install --frozen-lockfile
|
||||||
COPY painter-js/ .
|
COPY painter-js/ .
|
||||||
|
ENV VITE_TRANSPORT=websocket
|
||||||
RUN bun run build
|
RUN bun run build
|
||||||
|
|
||||||
FROM rust:1-alpine AS builder
|
FROM rust:1-alpine AS builder
|
||||||
@@ -13,10 +14,11 @@ COPY Cargo.toml Cargo.lock ./
|
|||||||
COPY crates/ ./crates/
|
COPY crates/ ./crates/
|
||||||
|
|
||||||
RUN mkdir -p painter-js/dist && echo '<html></html>' > painter-js/dist/index.html
|
RUN mkdir -p painter-js/dist && echo '<html></html>' > painter-js/dist/index.html
|
||||||
RUN cargo build --release --bin server 2>&1 || true
|
RUN cargo build --release --bin server --no-default-features --features websocket 2>&1 || true
|
||||||
|
|
||||||
COPY --from=frontend /app/painter-js/dist ./painter-js/dist
|
COPY --from=frontend /app/painter-js/dist ./painter-js/dist
|
||||||
RUN touch crates/adapters/http-axum/src/routes.rs && cargo build --release --bin server
|
RUN touch crates/adapters/http-axum/src/routes.rs && \
|
||||||
|
cargo build --release --bin server --no-default-features --features websocket
|
||||||
|
|
||||||
FROM scratch
|
FROM scratch
|
||||||
COPY --from=builder /app/target/release/server /painter
|
COPY --from=builder /app/target/release/server /painter
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const DEFAULT_BROADCAST_CAPACITY: usize = 1024;
|
|||||||
const DEFAULT_SNAPSHOT_INTERVAL_SECS: u64 = 300;
|
const DEFAULT_SNAPSHOT_INTERVAL_SECS: u64 = 300;
|
||||||
const DEFAULT_SNAPSHOT_MAX: usize = 5;
|
const DEFAULT_SNAPSHOT_MAX: usize = 5;
|
||||||
const DEFAULT_SNAPSHOT_DIR: &str = "snapshots/";
|
const DEFAULT_SNAPSHOT_DIR: &str = "snapshots/";
|
||||||
|
const DEFAULT_MAX_CONCURRENT_CANVAS_SENDS: usize = 20;
|
||||||
|
|
||||||
pub struct EnvConfigSource;
|
pub struct EnvConfigSource;
|
||||||
|
|
||||||
@@ -24,6 +25,10 @@ impl ConfigSource for EnvConfigSource {
|
|||||||
address: env_or("ADDRESS", DEFAULT_ADDRESS),
|
address: env_or("ADDRESS", DEFAULT_ADDRESS),
|
||||||
port: parse_env("PORT", DEFAULT_PORT)?,
|
port: parse_env("PORT", DEFAULT_PORT)?,
|
||||||
enable_cors: parse_bool_env("ENABLE_CORS", true),
|
enable_cors: parse_bool_env("ENABLE_CORS", true),
|
||||||
|
max_concurrent_canvas_sends: parse_env(
|
||||||
|
"MAX_CONCURRENT_CANVAS_SENDS",
|
||||||
|
DEFAULT_MAX_CONCURRENT_CANVAS_SENDS,
|
||||||
|
)?,
|
||||||
},
|
},
|
||||||
canvas: CanvasConfig {
|
canvas: CanvasConfig {
|
||||||
width: parse_env("CANVAS_WIDTH", DEFAULT_CANVAS_WIDTH)?,
|
width: parse_env("CANVAS_WIDTH", DEFAULT_CANVAS_WIDTH)?,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ edition.workspace = true
|
|||||||
application = { workspace = true }
|
application = { workspace = true }
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
axum = { workspace = true, features = ["ws"] }
|
axum = { workspace = true, features = ["ws"] }
|
||||||
|
flate2 = { workspace = true }
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
|
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::write::GzEncoder;
|
||||||
use futures::{SinkExt, StreamExt, stream::SplitSink};
|
use futures::{SinkExt, StreamExt, stream::SplitSink};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::{Semaphore, mpsc};
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
|
|
||||||
use crate::WsState;
|
use crate::WsState;
|
||||||
@@ -26,17 +30,19 @@ 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(
|
||||||
|
state
|
||||||
.connection_counter
|
.connection_counter
|
||||||
.fetch_add(1, Ordering::Relaxed)
|
.fetch_add(1, Ordering::Relaxed)
|
||||||
.to_string();
|
.to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
info!("WebSocket connected: {connection_id}");
|
info!("WebSocket connected: {connection_id}");
|
||||||
|
|
||||||
// Subscribe before snapshotting to avoid missing updates
|
// Subscribe before snapshotting to avoid missing updates
|
||||||
let subscription = state.app_state.broadcaster().subscribe();
|
let subscription = state.app_state.broadcaster().subscribe();
|
||||||
|
|
||||||
if !send_canvas_snapshot(&mut sender, &state.app_state).await {
|
if !send_canvas_snapshot(&mut sender, &state.app_state, &state.canvas_send_semaphore).await {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,17 +73,51 @@ async fn handle_connection(socket: WebSocket, state: Arc<WsState>) {
|
|||||||
application::soldiers::disconnect::execute(&state.app_state, &connection_id);
|
application::soldiers::disconnect::execute(&state.app_state, &connection_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_canvas_snapshot(sender: &mut WsSender, state: &AppState) -> bool {
|
async fn send_canvas_snapshot(
|
||||||
|
sender: &mut WsSender,
|
||||||
|
state: &AppState,
|
||||||
|
semaphore: &Semaphore,
|
||||||
|
) -> bool {
|
||||||
|
let Ok(_permit) = semaphore.acquire().await else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
let pixels = application::canvas::get_state::execute(state);
|
let pixels = application::canvas::get_state::execute(state);
|
||||||
let bytes = Color::collect_as_bytes(&pixels);
|
let raw_bytes = Color::collect_as_bytes(&pixels);
|
||||||
sender.send(Message::Binary(bytes.into())).await.is_ok()
|
|
||||||
|
let Some(compressed) = gzip_compress(&raw_bytes) else {
|
||||||
|
error!("Failed to compress canvas snapshot");
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Sending canvas snapshot: {}KB raw -> {}KB gzip",
|
||||||
|
raw_bytes.len() / 1024,
|
||||||
|
compressed.len() / 1024
|
||||||
|
);
|
||||||
|
|
||||||
|
sender
|
||||||
|
.send(Message::Binary(compressed.into()))
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn gzip_compress(data: &[u8]) -> Option<Vec<u8>> {
|
||||||
|
let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
|
||||||
|
encoder.write_all(data).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() => {
|
||||||
@@ -91,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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,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 {
|
||||||
@@ -128,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,
|
||||||
|
|||||||
@@ -6,16 +6,19 @@ use std::sync::atomic::AtomicU64;
|
|||||||
|
|
||||||
use application::AppState;
|
use application::AppState;
|
||||||
use axum::{Router, routing::get};
|
use axum::{Router, routing::get};
|
||||||
|
use tokio::sync::Semaphore;
|
||||||
|
|
||||||
pub(crate) struct WsState {
|
pub(crate) struct WsState {
|
||||||
app_state: Arc<AppState>,
|
app_state: Arc<AppState>,
|
||||||
connection_counter: AtomicU64,
|
connection_counter: AtomicU64,
|
||||||
|
canvas_send_semaphore: Arc<Semaphore>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_router(state: Arc<AppState>) -> Router {
|
pub fn build_router(state: Arc<AppState>, max_concurrent_canvas_sends: usize) -> Router {
|
||||||
let ws_state = Arc::new(WsState {
|
let ws_state = Arc::new(WsState {
|
||||||
app_state: state,
|
app_state: state,
|
||||||
connection_counter: AtomicU64::new(0),
|
connection_counter: AtomicU64::new(0),
|
||||||
|
canvas_send_semaphore: Arc::new(Semaphore::new(max_concurrent_canvas_sends)),
|
||||||
});
|
});
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ pub struct ServerConfig {
|
|||||||
pub address: String,
|
pub address: String,
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
pub enable_cors: bool,
|
pub enable_cors: bool,
|
||||||
|
pub max_concurrent_canvas_sends: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version.workspace = true
|
|||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["socketio"]
|
default = ["websocket"]
|
||||||
socketio = ["dep:socketio", "dep:socketioxide"]
|
socketio = ["dep:socketio", "dep:socketioxide"]
|
||||||
websocket = ["dep:websocket"]
|
websocket = ["dep:websocket"]
|
||||||
|
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ fn build_app(
|
|||||||
state: Arc<AppState>,
|
state: Arc<AppState>,
|
||||||
config: &AppConfig,
|
config: &AppConfig,
|
||||||
) -> Result<axum::Router, Box<dyn std::error::Error>> {
|
) -> Result<axum::Router, Box<dyn std::error::Error>> {
|
||||||
let ws_router = websocket::build_router(state);
|
let ws_router = websocket::build_router(state, config.server.max_concurrent_canvas_sends);
|
||||||
let http_router = http_axum::build_router(config.server.enable_cors, &config.rate_limit)?;
|
let http_router = http_axum::build_router(config.server.enable_cors, &config.rate_limit)?;
|
||||||
Ok(ws_router.merge(http_router))
|
Ok(ws_router.merge(http_router))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
20
painter-js/src/domain/app-state.js
Normal file
20
painter-js/src/domain/app-state.js
Normal 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);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
17
painter-js/src/infrastructure/event-bus.js
Normal file
17
painter-js/src/infrastructure/event-bus.js
Normal 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));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
51
painter-js/src/infrastructure/keyboard.js
Normal file
51
painter-js/src/infrastructure/keyboard.js
Normal 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -13,6 +13,13 @@ const createSocketIoTransport = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const decompressGzip = async (buffer) => {
|
||||||
|
const stream = new Blob([buffer])
|
||||||
|
.stream()
|
||||||
|
.pipeThrough(new DecompressionStream("gzip"));
|
||||||
|
return new Response(stream).arrayBuffer();
|
||||||
|
};
|
||||||
|
|
||||||
const createWebSocketTransport = () => {
|
const createWebSocketTransport = () => {
|
||||||
const handlers = {};
|
const handlers = {};
|
||||||
const wsUrl = isDebug
|
const wsUrl = isDebug
|
||||||
@@ -38,9 +45,10 @@ const createWebSocketTransport = () => {
|
|||||||
|
|
||||||
ws.addEventListener("open", () => dispatch("connect"));
|
ws.addEventListener("open", () => dispatch("connect"));
|
||||||
|
|
||||||
ws.addEventListener("message", (event) => {
|
ws.addEventListener("message", async (event) => {
|
||||||
if (event.data instanceof ArrayBuffer) {
|
if (event.data instanceof ArrayBuffer) {
|
||||||
const pixels = new Uint32Array(event.data);
|
const decompressed = await decompressGzip(event.data);
|
||||||
|
const pixels = new Uint32Array(decompressed);
|
||||||
dispatch("canvas_state", Array.from(pixels));
|
dispatch("canvas_state", Array.from(pixels));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
34
painter-js/src/tools/hand-tool.js
Normal file
34
painter-js/src/tools/hand-tool.js
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
46
painter-js/src/tools/paint-tool.js
Normal file
46
painter-js/src/tools/paint-tool.js
Normal 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(),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 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);
|
const remaining = timeRemaining(lastPlacementTime);
|
||||||
countdownDiv.textContent =
|
const isCoolingDown = remaining > 0;
|
||||||
remaining > 0
|
|
||||||
|
countdown.textContent = isCoolingDown
|
||||||
? `You can place a pixel in ${Math.ceil(remaining / 1000)} seconds`
|
? `You can place a pixel in ${Math.ceil(remaining / 1000)} seconds`
|
||||||
: "You can place a pixel now";
|
: "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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
|
||||||
};
|
|
||||||
65
painter-js/src/ui/tool-manager.js
Normal file
65
painter-js/src/ui/tool-manager.js
Normal 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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user