v1.0.0 — Hexagonal architecture rewrite
All checks were successful
CI / ci (push) Successful in 7m16s
All checks were successful
CI / ci (push) Successful in 7m16s
Restructure the monolithic 252-line main.rs into a 10-crate workspace with clean hexagonal architecture, swappable adapters, and a production- ready deployment pipeline. Backend architecture: - domain: Canvas, Color/Position/PixelUpdate value objects, port traits (CanvasStore, CanvasPersistence, EventBroadcaster), BroadcastEvent - application: use cases (place_pixel, get_state, save/restore snapshot, connect/disconnect), AppState with Arc snapshot cache - config: AppConfig structs + ConfigSource trait - api-types: shared DTOs, event name constants - adapters: config-env, canvas-file, http-axum (rust-embed), socketio, websocket — all behind port traits, swappable via feature flags - server: composition root with graceful shutdown (SIGTERM/SIGINT) Frontend: - Transport abstraction: Socket.IO and native WebSocket via VITE_TRANSPORT - Canvas zoom/pan with mouse wheel, pinch-to-zoom, and +/- buttons - ImageData rendering (~50x faster than fillRect loop) - Touch support, responsive CSS scaling, mobile-friendly layout - OG/Twitter Card meta tags for rich link previews Production: - Docker: musl static build on scratch — 2.73MB image - CI workflows for Gitea and GitHub Actions (fmt, clippy, test, Docker push) - deploy.sh for private registry - 39 unit tests across domain and application - Zero unwraps, zero unsafe, graceful error handling with tracing - Periodic canvas snapshots with rotation, restored on startup - All config via environment variables with typed defaults
This commit is contained in:
Binary file not shown.
@@ -1,267 +0,0 @@
|
||||
import {
|
||||
hexToU32,
|
||||
u32ToHex,
|
||||
getColorFromElementCSS,
|
||||
rgbToHex,
|
||||
} from "./utils.js";
|
||||
import {
|
||||
pixelSize,
|
||||
pixelCooldown,
|
||||
canvasEndpoint,
|
||||
WIDTH,
|
||||
HEIGHT,
|
||||
} from "./constants.js";
|
||||
|
||||
let socket = null;
|
||||
|
||||
const canvas = document.getElementById("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const countdownDiv = document.getElementById("countdown");
|
||||
let lastPixelTime = parseInt(localStorage.getItem("lastPixelTime") || "0");
|
||||
|
||||
const colorPicker = document.getElementById("color-picker");
|
||||
const redButton = document.getElementById("red");
|
||||
const greenButton = document.getElementById("green");
|
||||
const blueButton = document.getElementById("blue");
|
||||
const yellowButton = document.getElementById("yellow");
|
||||
const purpleButton = document.getElementById("purple");
|
||||
const pinkButton = document.getElementById("pink");
|
||||
const cyanButton = document.getElementById("cyan");
|
||||
const whiteButton = document.getElementById("white");
|
||||
const blackButton = document.getElementById("black");
|
||||
const orangeButton = document.getElementById("orange");
|
||||
const brownButton = document.getElementById("brown");
|
||||
|
||||
const currentColorSpan = document.getElementById("current-color-span");
|
||||
const toggleGridToggle = document.getElementById("toggle-grid");
|
||||
const placePixelButton = document.getElementById("place-pixel");
|
||||
const saveCanvasButton = document.getElementById("save-canvas");
|
||||
|
||||
colorPicker.value = localStorage.getItem("currentColor") || "#000000";
|
||||
|
||||
let currentColor = colorPicker.value;
|
||||
let showGrid = toggleGridToggle.checked;
|
||||
currentColorSpan.style.backgroundColor = currentColor;
|
||||
|
||||
let canvasState = [];
|
||||
let confirmPlacePixel = false;
|
||||
let previewPixel = null;
|
||||
|
||||
const setCurrentColor = (color, isColorPicker = false) => {
|
||||
const hexColor = isColorPicker ? color : rgbToHex(color);
|
||||
currentColor = hexColor;
|
||||
currentColorSpan.style.backgroundColor = hexColor;
|
||||
localStorage.setItem("currentColor", hexColor);
|
||||
};
|
||||
|
||||
const handleColorPicker = () => {
|
||||
colorPicker.addEventListener("input", (event) => {
|
||||
setCurrentColor(event.target.value, true);
|
||||
});
|
||||
|
||||
redButton.addEventListener("click", () => {
|
||||
setCurrentColor(getColorFromElementCSS(redButton));
|
||||
});
|
||||
|
||||
greenButton.addEventListener("click", () => {
|
||||
setCurrentColor(getColorFromElementCSS(greenButton));
|
||||
});
|
||||
|
||||
blueButton.addEventListener("click", () => {
|
||||
setCurrentColor(getColorFromElementCSS(blueButton));
|
||||
});
|
||||
|
||||
yellowButton.addEventListener("click", () => {
|
||||
setCurrentColor(getColorFromElementCSS(yellowButton));
|
||||
});
|
||||
|
||||
purpleButton.addEventListener("click", () => {
|
||||
setCurrentColor(getColorFromElementCSS(purpleButton));
|
||||
});
|
||||
|
||||
pinkButton.addEventListener("click", () => {
|
||||
setCurrentColor(getColorFromElementCSS(pinkButton));
|
||||
});
|
||||
|
||||
cyanButton.addEventListener("click", () => {
|
||||
setCurrentColor(getColorFromElementCSS(cyanButton));
|
||||
});
|
||||
|
||||
whiteButton.addEventListener("click", () => {
|
||||
setCurrentColor(getColorFromElementCSS(whiteButton));
|
||||
});
|
||||
|
||||
blackButton.addEventListener("click", () => {
|
||||
setCurrentColor(getColorFromElementCSS(blackButton));
|
||||
});
|
||||
|
||||
orangeButton.addEventListener("click", () => {
|
||||
setCurrentColor(getColorFromElementCSS(orangeButton));
|
||||
});
|
||||
|
||||
brownButton.addEventListener("click", () => {
|
||||
setCurrentColor(getColorFromElementCSS(brownButton));
|
||||
});
|
||||
};
|
||||
|
||||
const fetchCanvasState = async () => {
|
||||
fetch(canvasEndpoint)
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
canvasState = data;
|
||||
drawCanvasState(data);
|
||||
})
|
||||
.catch((error) => {
|
||||
alert("Error fetching canvas state from server. Please try again later.");
|
||||
});
|
||||
};
|
||||
|
||||
const drawCanvasState = (canvasState) => {
|
||||
for (let y = 0; y < HEIGHT; y++) {
|
||||
for (let x = 0; x < WIDTH; x++) {
|
||||
const index = y * WIDTH + x;
|
||||
const color = u32ToHex(canvasState[index]);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(x * pixelSize, y * pixelSize, pixelSize, pixelSize);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const checkIfCanPlacePixel = () => {
|
||||
const now = Date.now();
|
||||
return now - lastPixelTime >= pixelCooldown;
|
||||
};
|
||||
|
||||
const setLastPixelTime = () => {
|
||||
lastPixelTime = Date.now();
|
||||
localStorage.setItem("lastPixelTime", lastPixelTime.toString());
|
||||
};
|
||||
|
||||
const handlePlacePixel = (pixelData) => {
|
||||
if (!checkIfCanPlacePixel()) {
|
||||
alert("You can't place a pixel yet");
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit("place-pixel", pixelData);
|
||||
const index = pixelData.y * WIDTH + pixelData.x;
|
||||
canvasState[index] = pixelData.color;
|
||||
setLastPixelTime();
|
||||
pixelData = null;
|
||||
};
|
||||
|
||||
canvas.addEventListener("click", (event) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = Math.floor((event.clientX - rect.left) / pixelSize);
|
||||
const y = Math.floor((event.clientY - rect.top) / pixelSize);
|
||||
|
||||
const color = hexToU32(currentColor);
|
||||
const oldPreviewPixel = previewPixel;
|
||||
|
||||
const update = { x, y, color };
|
||||
|
||||
if (confirmPlacePixel) {
|
||||
handlePlacePixel(update);
|
||||
} else {
|
||||
previewPixel = update;
|
||||
}
|
||||
|
||||
if (previewPixel) {
|
||||
if (oldPreviewPixel) {
|
||||
ctx.clearRect(
|
||||
oldPreviewPixel.x * pixelSize,
|
||||
oldPreviewPixel.y * pixelSize,
|
||||
pixelSize,
|
||||
pixelSize
|
||||
);
|
||||
}
|
||||
|
||||
ctx.fillStyle = `rgba(${(color >> 16) & 0xff}, ${(color >> 8) & 0xff}, ${
|
||||
color & 0xff
|
||||
}, 0.5)`;
|
||||
ctx.fillRect(x * pixelSize, y * pixelSize, pixelSize, pixelSize);
|
||||
}
|
||||
});
|
||||
|
||||
const removePreviewPixel = () => {
|
||||
previewPixel = null;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
drawCanvasState(canvasState);
|
||||
};
|
||||
|
||||
const drawGrid = () => {
|
||||
ctx.strokeStyle = "#000";
|
||||
for (let x = 0; x < canvas.width; x += pixelSize) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, canvas.height);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let y = 0; y < canvas.height; y += pixelSize) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(canvas.width, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleGrid = () => {
|
||||
toggleGridToggle.addEventListener("change", (event) => {
|
||||
showGrid = event.target.checked;
|
||||
localStorage.setItem("showGrid", showGrid);
|
||||
if (showGrid) {
|
||||
drawGrid();
|
||||
} else {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
drawCanvasState(canvasState);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
window.onkeydown = (event) => {
|
||||
// on enter (keycode 13 is enter)
|
||||
if (event.keyCode === 13) {
|
||||
if (previewPixel) {
|
||||
handlePlacePixel(previewPixel);
|
||||
removePreviewPixel();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
placePixelButton.addEventListener("click", () => {
|
||||
if (previewPixel) {
|
||||
handlePlacePixel(previewPixel);
|
||||
removePreviewPixel();
|
||||
}
|
||||
});
|
||||
|
||||
saveCanvasButton.addEventListener("click", () => {
|
||||
const a = document.createElement("a");
|
||||
a.href = canvas.toDataURL();
|
||||
a.download = "canvas.png";
|
||||
a.click();
|
||||
});
|
||||
|
||||
handleColorPicker();
|
||||
handleToggleGrid();
|
||||
|
||||
export const handleSocketEvents = (_socket) => {
|
||||
socket = _socket;
|
||||
socket.on("connect", () => {
|
||||
fetchCanvasState();
|
||||
});
|
||||
|
||||
socket.on("pixel-updated", (update) => {
|
||||
const color = u32ToHex(update.color);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(
|
||||
update.x * pixelSize,
|
||||
update.y * pixelSize,
|
||||
pixelSize,
|
||||
pixelSize
|
||||
);
|
||||
|
||||
const index = update.y * WIDTH + update.x;
|
||||
canvasState[index] = update.color;
|
||||
});
|
||||
};
|
||||
@@ -1,139 +0,0 @@
|
||||
import * as three from 'three';
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||
|
||||
let scene, camera, renderer, character, mixer, clock;
|
||||
let walkAction, idleAction;
|
||||
let moving = false;
|
||||
const keys = {}
|
||||
|
||||
let boxes = [];
|
||||
|
||||
const container = document.getElementById('challenge');
|
||||
|
||||
const onWindowResize = () => {
|
||||
camera.aspect = container.clientWidth / container.clientHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(container.clientWidth, container.clientHeight);
|
||||
}
|
||||
|
||||
const onKeyDown = (e) => {
|
||||
keys[e.code] = true;
|
||||
}
|
||||
|
||||
const onKeyUp = (e) => {
|
||||
keys[e.code] = false;
|
||||
}
|
||||
|
||||
const init = () => {
|
||||
scene = new three.Scene();
|
||||
camera = new three.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
|
||||
renderer = new three.WebGLRenderer({ antialias: true});
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
clock = new three.Clock();
|
||||
|
||||
// Adjust light positions and intensity
|
||||
const light = new three.DirectionalLight(0xffffff, 1);
|
||||
light.position.set(5, 10, 7.5);
|
||||
light.castShadow = true;
|
||||
scene.add(light);
|
||||
|
||||
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const box = new three.Mesh(
|
||||
new three.BoxGeometry(1, 1, 1),
|
||||
new three.MeshStandardMaterial({ color: 0x00ff00 })
|
||||
);
|
||||
box.position.set(Math.random() * 10 - 5, 0.5, Math.random() * 10 - 5);
|
||||
box.castShadow = true;
|
||||
scene.add(box);
|
||||
boxes.push(box);
|
||||
}
|
||||
|
||||
const loader = new GLTFLoader();
|
||||
loader.load('src/Astronaut.glb',(gltf) => {
|
||||
character = gltf.scene;
|
||||
scene.add(character);
|
||||
|
||||
mixer = new three.AnimationMixer(character);
|
||||
gltf.animations.forEach((clip) => {
|
||||
if (clip.name === 'CharacterArmature|Walk') {
|
||||
walkAction = mixer.clipAction(clip);
|
||||
}
|
||||
if (clip.name === 'CharacterArmature|Idle') {
|
||||
idleAction = mixer.clipAction(clip);
|
||||
}
|
||||
});
|
||||
|
||||
if (walkAction) walkAction.play();
|
||||
if (idleAction) idleAction.play();
|
||||
|
||||
character.position.set(-10, 0, -60);
|
||||
character.rotation.y = Math.PI;
|
||||
})
|
||||
|
||||
//blue sky
|
||||
scene.background = new three.Color(0x87ceeb);
|
||||
|
||||
camera.position.set(20, 10, 10)
|
||||
camera.lookAt(0, 10, 0)
|
||||
|
||||
window.addEventListener('resize', onWindowResize, false);
|
||||
document.addEventListener('keydown', onKeyDown, false);
|
||||
document.addEventListener('keyup', onKeyUp, false);
|
||||
}
|
||||
|
||||
const animate = () => {
|
||||
requestAnimationFrame(animate);
|
||||
|
||||
const delta = clock.getDelta();
|
||||
if (mixer) mixer.update(delta);
|
||||
|
||||
if (character) {
|
||||
if (keys['KeyW']) {
|
||||
character.position.z -= 0.1;
|
||||
character.rotation.y = Math.PI; // North
|
||||
}
|
||||
|
||||
if (keys['KeyS']) {
|
||||
character.position.z += 0.1;
|
||||
character.rotation.y = 0; // South
|
||||
}
|
||||
|
||||
if (keys['KeyA']) {
|
||||
character.position.x -= 0.1;
|
||||
character.rotation.y = Math.PI / 2; // West
|
||||
}
|
||||
|
||||
if (keys['KeyD']) {
|
||||
character.position.x += 0.1;
|
||||
character.rotation.y = -Math.PI / 2; // East
|
||||
}
|
||||
|
||||
// check if any key is pressed
|
||||
if (keys['KeyW'] || keys['KeyS'] || keys['KeyA'] || keys['KeyD']) {
|
||||
moving = true;
|
||||
} else {
|
||||
moving = false;
|
||||
}
|
||||
|
||||
// Play walking animation if moving
|
||||
if (mixer) {
|
||||
if (walkAction && idleAction) {
|
||||
walkAction.enabled = moving;
|
||||
idleAction.enabled = !moving;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
|
||||
const threejs = import.meta.env.VITE_THREE_JS === "true";
|
||||
if (threejs) {
|
||||
init();
|
||||
animate();
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
const isDebug = import.meta.env.VITE_IS_DEBUG === "true";
|
||||
|
||||
export const pixelSize = 10;
|
||||
export const pixelCooldown = 10 * 1000; // 10 seconds
|
||||
export const canvasEndpoint = isDebug
|
||||
? "http://localhost:3000/canvas/"
|
||||
: "/canvas/";
|
||||
export const checkEndpoint = isDebug
|
||||
? "http://localhost:3000/check/"
|
||||
: "/check/";
|
||||
export const WIDTH = 500;
|
||||
export const HEIGHT = 500;
|
||||
@@ -1,20 +0,0 @@
|
||||
import { pixelCooldown } from "./constants.js";
|
||||
|
||||
const countdownDiv = document.getElementById("countdown");
|
||||
|
||||
export const updateCountdown = () => {
|
||||
setInterval(() => {
|
||||
const lastPixelTime = parseInt(
|
||||
localStorage.getItem("lastPixelTime") || "0"
|
||||
);
|
||||
const now = Date.now();
|
||||
const timeLeft = Math.max(0, pixelCooldown - (now - lastPixelTime));
|
||||
if (timeLeft > 0) {
|
||||
countdownDiv.textContent = `You can place a pixel in ${Math.ceil(
|
||||
timeLeft / 1000
|
||||
)} seconds`;
|
||||
} else {
|
||||
countdownDiv.textContent = "You can place a pixel now";
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
11
painter-js/src/domain/canvas-state.js
Normal file
11
painter-js/src/domain/canvas-state.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { WIDTH } from "./constants.js";
|
||||
|
||||
export const getPixelIndex = (x, y) => y * WIDTH + x;
|
||||
|
||||
export const setPixel = (state, x, y, color) => {
|
||||
state[getPixelIndex(x, y)] = color;
|
||||
};
|
||||
|
||||
export const getPixel = (state, x, y) => {
|
||||
return state[getPixelIndex(x, y)];
|
||||
};
|
||||
@@ -6,13 +6,16 @@ export const hexToU32 = (color) => {
|
||||
return parseInt(color.slice(1), 16);
|
||||
};
|
||||
|
||||
export const getColorFromElementCSS = (element) => {
|
||||
return window.getComputedStyle(element).backgroundColor;
|
||||
};
|
||||
|
||||
export const rgbToHex = (rgbProperty) => {
|
||||
const rgb = rgbProperty.match(/\d+/g);
|
||||
return `#${rgb
|
||||
.map((x) => parseInt(x).toString(16).padStart(2, "0"))
|
||||
.join("")}`;
|
||||
};
|
||||
|
||||
export const u32ToRGBA = (color, alpha) => {
|
||||
const r = (color >> 16) & 0xff;
|
||||
const g = (color >> 8) & 0xff;
|
||||
const b = color & 0xff;
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
};
|
||||
4
painter-js/src/domain/constants.js
Normal file
4
painter-js/src/domain/constants.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export const PIXEL_SIZE = 1;
|
||||
export const PIXEL_COOLDOWN = 10 * 1000;
|
||||
export const WIDTH = 500;
|
||||
export const HEIGHT = 500;
|
||||
9
painter-js/src/domain/cooldown.js
Normal file
9
painter-js/src/domain/cooldown.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { PIXEL_COOLDOWN } from "./constants.js";
|
||||
|
||||
export const canPlacePixel = (lastPlacementTime) => {
|
||||
return Date.now() - lastPlacementTime >= PIXEL_COOLDOWN;
|
||||
};
|
||||
|
||||
export const timeRemaining = (lastPlacementTime) => {
|
||||
return Math.max(0, PIXEL_COOLDOWN - (Date.now() - lastPlacementTime));
|
||||
};
|
||||
19
painter-js/src/domain/coords.js
Normal file
19
painter-js/src/domain/coords.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { WIDTH, HEIGHT } from "./constants.js";
|
||||
|
||||
export const getCanvasCoords = (event, canvas) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const clientX = event.touches ? event.touches[0].clientX : event.clientX;
|
||||
const clientY = event.touches ? event.touches[0].clientY : event.clientY;
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
return {
|
||||
x: Math.min(
|
||||
Math.max(Math.floor((clientX - rect.left) * scaleX), 0),
|
||||
WIDTH - 1,
|
||||
),
|
||||
y: Math.min(
|
||||
Math.max(Math.floor((clientY - rect.top) * scaleY), 0),
|
||||
HEIGHT - 1,
|
||||
),
|
||||
};
|
||||
};
|
||||
5
painter-js/src/infrastructure/api.js
Normal file
5
painter-js/src/infrastructure/api.js
Normal file
@@ -0,0 +1,5 @@
|
||||
const isDebug = import.meta.env.VITE_IS_DEBUG === "true";
|
||||
|
||||
const CHECK_ENDPOINT = isDebug ? "http://localhost:3000/check/" : "/check/";
|
||||
|
||||
export const checkServer = () => fetch(CHECK_ENDPOINT);
|
||||
72
painter-js/src/infrastructure/socket-client.js
Normal file
72
painter-js/src/infrastructure/socket-client.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import io from "socket.io-client";
|
||||
|
||||
const isDebug = import.meta.env.VITE_IS_DEBUG === "true";
|
||||
const transport = import.meta.env.VITE_TRANSPORT || "socketio";
|
||||
|
||||
const createSocketIoTransport = () => {
|
||||
const url = isDebug ? "ws://localhost:3000" : undefined;
|
||||
const socket = url ? io(url) : io({ transports: ["websocket"] });
|
||||
|
||||
return {
|
||||
on: (event, handler) => socket.on(event, handler),
|
||||
emit: (event, data) => socket.emit(event, data),
|
||||
};
|
||||
};
|
||||
|
||||
const createWebSocketTransport = () => {
|
||||
const handlers = {};
|
||||
const wsUrl = isDebug
|
||||
? "ws://localhost:3000/ws"
|
||||
: `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
const on = (event, handler) => {
|
||||
if (!handlers[event]) handlers[event] = [];
|
||||
handlers[event].push(handler);
|
||||
};
|
||||
|
||||
const emit = (event, data) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(JSON.stringify({ type: event, ...data }));
|
||||
};
|
||||
|
||||
const dispatch = (event, ...args) => {
|
||||
(handlers[event] || []).forEach((handler) => handler(...args));
|
||||
};
|
||||
|
||||
ws.addEventListener("open", () => dispatch("connect"));
|
||||
|
||||
ws.addEventListener("message", (event) => {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
const pixels = new Uint32Array(event.data);
|
||||
dispatch("canvas_state", Array.from(pixels));
|
||||
return;
|
||||
}
|
||||
|
||||
const message = JSON.parse(event.data);
|
||||
switch (message.type) {
|
||||
case "pixel-updated":
|
||||
dispatch("pixel-updated", message);
|
||||
break;
|
||||
case "current_soldiers":
|
||||
dispatch("current_soldiers", message.count);
|
||||
break;
|
||||
case "error":
|
||||
dispatch("error", message.message);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener("close", () => dispatch("disconnect"));
|
||||
|
||||
return { on, emit };
|
||||
};
|
||||
|
||||
export const createSocketConnection = () => {
|
||||
if (transport === "websocket") {
|
||||
return createWebSocketTransport();
|
||||
}
|
||||
return createSocketIoTransport();
|
||||
};
|
||||
@@ -1,58 +1,88 @@
|
||||
import { connectToWS } from "./socket.js";
|
||||
import "./canvas.js";
|
||||
import "./counter.js";
|
||||
import { updateCountdown } from "./counter.js";
|
||||
import { checkEndpoint, pixelSize } from "./constants.js";
|
||||
import { handleSocketEvents } from "./canvas.js";
|
||||
import "./challenge.js"
|
||||
import { createSocketConnection } from "./infrastructure/socket-client.js";
|
||||
import { checkServer } from "./infrastructure/api.js";
|
||||
import { createCanvasRenderer } from "./ui/canvas-renderer.js";
|
||||
import { createColorPalette } from "./ui/color-palette.js";
|
||||
import { createPixelPlacer } from "./ui/pixel-placer.js";
|
||||
import { startCooldownDisplay } from "./ui/cooldown-display.js";
|
||||
import { createCanvasViewport } from "./ui/canvas-viewport.js";
|
||||
import { setPixel } from "./domain/canvas-state.js";
|
||||
import { getCanvasCoords } from "./domain/coords.js";
|
||||
|
||||
const isDebug = import.meta.env.VITE_IS_DEBUG === "true";
|
||||
|
||||
const currentSoldiersSpan = document.getElementById("current-soldiers");
|
||||
|
||||
let coords = [];
|
||||
const canvas = document.getElementById("canvas");
|
||||
const canvasEl = document.getElementById("canvas");
|
||||
const coordsText = document.getElementById("coords");
|
||||
const ogCanvasStyle = canvas.style.display;
|
||||
canvas.style.display = "none";
|
||||
const currentSoldiersSpan = document.getElementById("current-soldiers");
|
||||
const statusEl = document.getElementById("connection-status");
|
||||
|
||||
fetch(checkEndpoint)
|
||||
const savedDisplay = canvasEl.style.display;
|
||||
canvasEl.style.display = "none";
|
||||
|
||||
let canvasState = [];
|
||||
|
||||
const renderer = createCanvasRenderer(canvasEl);
|
||||
const palette = createColorPalette();
|
||||
|
||||
startCooldownDisplay();
|
||||
createCanvasViewport(canvasEl);
|
||||
|
||||
canvasEl.addEventListener("mousemove", (event) => {
|
||||
const { x, y } = getCanvasCoords(event, canvasEl);
|
||||
coordsText.textContent = `${x}, ${y}`;
|
||||
});
|
||||
|
||||
document.getElementById("save-canvas").addEventListener("click", () => {
|
||||
const a = document.createElement("a");
|
||||
a.href = renderer.toDataURL();
|
||||
a.download = "canvas.png";
|
||||
a.click();
|
||||
});
|
||||
|
||||
const showStatus = (message, isError) => {
|
||||
if (!statusEl) return;
|
||||
statusEl.textContent = message;
|
||||
statusEl.className = isError
|
||||
? "text-red-500 text-sm"
|
||||
: "text-green-500 text-sm";
|
||||
};
|
||||
|
||||
checkServer()
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
const socket = connectToWS();
|
||||
if (!response.ok) throw new Error("Server unavailable");
|
||||
|
||||
socket.on("connect", () => {
|
||||
canvas.style.display = ogCanvasStyle;
|
||||
console.log("connect");
|
||||
});
|
||||
const socket = createSocketConnection();
|
||||
|
||||
socket.on("error", (message) => {
|
||||
alert(message);
|
||||
});
|
||||
socket.on("connect", () => {
|
||||
canvasEl.style.display = savedDisplay;
|
||||
showStatus("Connected", false);
|
||||
});
|
||||
|
||||
socket.on("current_soldiers", (currentSoldiers) => {
|
||||
currentSoldiersSpan.textContent = currentSoldiers;
|
||||
});
|
||||
socket.on("canvas_state", (data) => {
|
||||
canvasState = data;
|
||||
renderer.drawState(data);
|
||||
});
|
||||
|
||||
handleSocketEvents(socket);
|
||||
socket.on("error", (message) => showStatus(message, true));
|
||||
|
||||
requestAnimationFrame(updateCountdown);
|
||||
socket.on("current_soldiers", (count) => {
|
||||
currentSoldiersSpan.textContent = count;
|
||||
});
|
||||
|
||||
window.addEventListener("mousemove", (event) => {
|
||||
// get coordinates of the mouse inside the canvas
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = Math.floor((event.clientX - rect.left) / pixelSize);
|
||||
const y = Math.floor((event.clientY - rect.top) / pixelSize);
|
||||
coords = [x, y];
|
||||
socket.on("pixel-updated", (update) => {
|
||||
renderer.drawPixel(update.x, update.y, update.color);
|
||||
setPixel(canvasState, update.x, update.y, update.color);
|
||||
});
|
||||
|
||||
coordsText.textContent = `${x}, ${y}`;
|
||||
});
|
||||
} else {
|
||||
throw new Error("Can't connect to the server");
|
||||
}
|
||||
socket.on("disconnect", () => {
|
||||
showStatus("Disconnected — reconnecting...", true);
|
||||
});
|
||||
|
||||
createPixelPlacer({
|
||||
canvas: canvasEl,
|
||||
renderer,
|
||||
getColor: palette.getColor,
|
||||
getState: () => canvasState,
|
||||
socket,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
alert(
|
||||
"You have already connected to the server from another tab or window. Please close the other tab or window and refresh this page."
|
||||
);
|
||||
.catch(() => {
|
||||
showStatus("Cannot connect to server", true);
|
||||
});
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import io from "socket.io-client";
|
||||
|
||||
const isDebug = import.meta.env.VITE_IS_DEBUG === "true";
|
||||
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsHost = window.location.host;
|
||||
|
||||
let socket;
|
||||
|
||||
export const connectToWS = () => {
|
||||
if (isDebug) {
|
||||
socket = io("ws://localhost:3000");
|
||||
} else {
|
||||
socket = io(`${wsProtocol}//${wsHost}`, {
|
||||
transports: ["websocket"],
|
||||
});
|
||||
}
|
||||
|
||||
return socket;
|
||||
};
|
||||
|
||||
export default socket;
|
||||
@@ -2,20 +2,17 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
.rainbow-border {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
background: linear-gradient(to right, red, orange, yellow, green, blue, indigo, violet);
|
||||
.canvas-viewport {
|
||||
overflow: auto;
|
||||
width: min(92vw, 70vh);
|
||||
height: min(92vw, 70vh);
|
||||
border: 2px solid #94a3b8;
|
||||
border-radius: 4px;
|
||||
cursor: crosshair;
|
||||
}
|
||||
.rainbow-border::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background: white;
|
||||
z-index: -1; /* Place behind the content */
|
||||
|
||||
#canvas {
|
||||
image-rendering: pixelated;
|
||||
image-rendering: crisp-edges;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
39
painter-js/src/ui/canvas-renderer.js
Normal file
39
painter-js/src/ui/canvas-renderer.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { u32ToHex, u32ToRGBA } from "../domain/color.js";
|
||||
import { WIDTH, HEIGHT } from "../domain/constants.js";
|
||||
|
||||
export const createCanvasRenderer = (canvas) => {
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
const drawState = (state) => {
|
||||
const imageData = ctx.createImageData(WIDTH, HEIGHT);
|
||||
const data = imageData.data;
|
||||
for (let i = 0; i < state.length; i++) {
|
||||
const color = state[i];
|
||||
const offset = i * 4;
|
||||
data[offset] = (color >> 16) & 0xff;
|
||||
data[offset + 1] = (color >> 8) & 0xff;
|
||||
data[offset + 2] = color & 0xff;
|
||||
data[offset + 3] = 255;
|
||||
}
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
};
|
||||
|
||||
const drawPixel = (x, y, colorU32) => {
|
||||
ctx.fillStyle = u32ToHex(colorU32);
|
||||
ctx.fillRect(x, y, 1, 1);
|
||||
};
|
||||
|
||||
const drawPreview = (x, y, colorU32) => {
|
||||
ctx.fillStyle = u32ToRGBA(colorU32, 0.5);
|
||||
ctx.fillRect(x, y, 1, 1);
|
||||
};
|
||||
|
||||
const restorePixel = (state, x, y) => {
|
||||
ctx.fillStyle = u32ToHex(state[y * WIDTH + x]);
|
||||
ctx.fillRect(x, y, 1, 1);
|
||||
};
|
||||
|
||||
const toDataURL = () => canvas.toDataURL();
|
||||
|
||||
return { drawState, drawPixel, drawPreview, restorePixel, toDataURL };
|
||||
};
|
||||
78
painter-js/src/ui/canvas-viewport.js
Normal file
78
painter-js/src/ui/canvas-viewport.js
Normal file
@@ -0,0 +1,78 @@
|
||||
const MIN_ZOOM = 1;
|
||||
const MAX_ZOOM = 40;
|
||||
|
||||
export const createCanvasViewport = (canvas) => {
|
||||
const viewport = canvas.parentElement;
|
||||
const zoomLabel = document.getElementById("zoom-level");
|
||||
let zoom = 1;
|
||||
let baseSize = viewport.clientWidth;
|
||||
|
||||
const applyZoom = () => {
|
||||
const size = baseSize * zoom;
|
||||
canvas.style.width = `${size}px`;
|
||||
canvas.style.height = `${size}px`;
|
||||
if (zoomLabel) zoomLabel.textContent = `${zoom.toFixed(1)}x`;
|
||||
};
|
||||
|
||||
const setZoom = (newZoom, centerX, centerY) => {
|
||||
const oldZoom = zoom;
|
||||
zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, newZoom));
|
||||
if (zoom === oldZoom) return;
|
||||
|
||||
if (centerX !== undefined && centerY !== undefined) {
|
||||
const ratio = zoom / oldZoom;
|
||||
viewport.scrollLeft = (viewport.scrollLeft + centerX) * ratio - centerX;
|
||||
viewport.scrollTop = (viewport.scrollTop + centerY) * ratio - centerY;
|
||||
}
|
||||
|
||||
applyZoom();
|
||||
};
|
||||
|
||||
viewport.addEventListener("wheel", (event) => {
|
||||
event.preventDefault();
|
||||
const factor = event.deltaY > 0 ? 0.8 : 1.25;
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
setZoom(zoom * factor, event.clientX - rect.left, event.clientY - rect.top);
|
||||
});
|
||||
|
||||
let lastPinchDist = 0;
|
||||
viewport.addEventListener("touchmove", (event) => {
|
||||
if (event.touches.length !== 2) return;
|
||||
event.preventDefault();
|
||||
const dist = Math.hypot(
|
||||
event.touches[0].clientX - event.touches[1].clientX,
|
||||
event.touches[0].clientY - event.touches[1].clientY,
|
||||
);
|
||||
if (lastPinchDist > 0) {
|
||||
const midX =
|
||||
(event.touches[0].clientX + event.touches[1].clientX) / 2 -
|
||||
viewport.getBoundingClientRect().left;
|
||||
const midY =
|
||||
(event.touches[0].clientY + event.touches[1].clientY) / 2 -
|
||||
viewport.getBoundingClientRect().top;
|
||||
setZoom(zoom * (dist / lastPinchDist), midX, midY);
|
||||
}
|
||||
lastPinchDist = dist;
|
||||
});
|
||||
|
||||
viewport.addEventListener("touchend", () => {
|
||||
lastPinchDist = 0;
|
||||
});
|
||||
|
||||
document.getElementById("zoom-in")?.addEventListener("click", () => {
|
||||
setZoom(zoom * 1.5);
|
||||
applyZoom();
|
||||
});
|
||||
|
||||
document.getElementById("zoom-out")?.addEventListener("click", () => {
|
||||
setZoom(zoom / 1.5);
|
||||
applyZoom();
|
||||
});
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
baseSize = viewport.clientWidth;
|
||||
applyZoom();
|
||||
});
|
||||
|
||||
applyZoom();
|
||||
};
|
||||
53
painter-js/src/ui/color-palette.js
Normal file
53
painter-js/src/ui/color-palette.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import { rgbToHex } from "../domain/color.js";
|
||||
|
||||
const PALETTE_IDS = [
|
||||
"red",
|
||||
"green",
|
||||
"blue",
|
||||
"yellow",
|
||||
"purple",
|
||||
"pink",
|
||||
"cyan",
|
||||
"white",
|
||||
"black",
|
||||
"orange",
|
||||
"brown",
|
||||
];
|
||||
|
||||
export const createColorPalette = () => {
|
||||
const colorPicker = document.getElementById("color-picker");
|
||||
const currentColorSpan = document.getElementById("current-color-span");
|
||||
let activeButton = null;
|
||||
|
||||
let currentColor = localStorage.getItem("currentColor") || "#000000";
|
||||
colorPicker.value = currentColor;
|
||||
currentColorSpan.style.backgroundColor = currentColor;
|
||||
|
||||
const setColor = (hex, button) => {
|
||||
currentColor = hex;
|
||||
currentColorSpan.textContent = hex;
|
||||
currentColorSpan.style.backgroundColor = hex;
|
||||
localStorage.setItem("currentColor", hex);
|
||||
|
||||
if (activeButton)
|
||||
activeButton.classList.remove("ring-2", "ring-offset-2", "ring-cyan-400");
|
||||
if (button) {
|
||||
button.classList.add("ring-2", "ring-offset-2", "ring-cyan-400");
|
||||
activeButton = button;
|
||||
}
|
||||
};
|
||||
|
||||
colorPicker.addEventListener("input", (e) => setColor(e.target.value, null));
|
||||
|
||||
for (const id of PALETTE_IDS) {
|
||||
const button = document.getElementById(id);
|
||||
button.addEventListener("click", () => {
|
||||
setColor(
|
||||
rgbToHex(window.getComputedStyle(button).backgroundColor),
|
||||
button,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return { getColor: () => currentColor };
|
||||
};
|
||||
16
painter-js/src/ui/cooldown-display.js
Normal file
16
painter-js/src/ui/cooldown-display.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { timeRemaining } from "../domain/cooldown.js";
|
||||
|
||||
export const startCooldownDisplay = () => {
|
||||
const countdownDiv = document.getElementById("countdown");
|
||||
|
||||
setInterval(() => {
|
||||
const lastPlacementTime = parseInt(
|
||||
localStorage.getItem("lastPixelTime") || "0",
|
||||
);
|
||||
const remaining = timeRemaining(lastPlacementTime);
|
||||
countdownDiv.textContent =
|
||||
remaining > 0
|
||||
? `You can place a pixel in ${Math.ceil(remaining / 1000)} seconds`
|
||||
: "You can place a pixel now";
|
||||
}, 1000);
|
||||
};
|
||||
57
painter-js/src/ui/pixel-placer.js
Normal file
57
painter-js/src/ui/pixel-placer.js
Normal file
@@ -0,0 +1,57 @@
|
||||
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);
|
||||
};
|
||||
Reference in New Issue
Block a user