From 9a56afd09ff8e798b7e5c2f11988bac561a0d661 Mon Sep 17 00:00:00 2001 From: tonocatmeow Date: Sat, 18 Jul 2026 12:17:09 -0500 Subject: [PATCH] inital commit --- .gitignore | 4 + package-lock.json | 82 +++++++ package.json | 20 ++ public/index.html | 30 +++ public/style.css | 93 +++++++ src/client/index.ts | 361 +++++++++++++++++++++++++++ src/client/particles.ts | 94 +++++++ src/client/render.ts | 533 ++++++++++++++++++++++++++++++++++++++++ src/client/sfx.ts | 67 +++++ src/server/index.ts | 79 ++++++ src/server/room.ts | 369 ++++++++++++++++++++++++++++ src/shared/constants.ts | 39 +++ src/shared/physics.ts | 123 ++++++++++ src/shared/protocol.ts | 63 +++++ tools/smoke.mjs | 70 ++++++ tsconfig.client.json | 14 ++ tsconfig.server.json | 14 ++ 17 files changed, 2055 insertions(+) create mode 100644 .gitignore create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/index.html create mode 100644 public/style.css create mode 100644 src/client/index.ts create mode 100644 src/client/particles.ts create mode 100644 src/client/render.ts create mode 100644 src/client/sfx.ts create mode 100644 src/server/index.ts create mode 100644 src/server/room.ts create mode 100644 src/shared/constants.ts create mode 100644 src/shared/physics.ts create mode 100644 src/shared/protocol.ts create mode 100644 tools/smoke.mjs create mode 100644 tsconfig.client.json create mode 100644 tsconfig.server.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f8a1b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +public/js/ +server.log diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b74eb1a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,82 @@ +{ + "name": "yellow-snow-wars", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "yellow-snow-wars", + "version": "1.0.0", + "dependencies": { + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "@types/ws": "^8.5.13", + "typescript": "^5.7.2" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..32ee130 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "yellow-snow-wars", + "version": "1.0.0", + "description": "2D multiplayer arena: snowmen throwing yellow snowballs. Rounds + points.", + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.server.json && tsc -p tsconfig.client.json", + "start": "node dist/server/index.js", + "dev": "npm run build && node dist/server/index.js", + "smoke": "node tools/smoke.mjs" + }, + "dependencies": { + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "@types/ws": "^8.5.13", + "typescript": "^5.7.2" + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..5d43e1e --- /dev/null +++ b/public/index.html @@ -0,0 +1,30 @@ + + + + + + + Yellow Snow Wars + + + + + +
+
+

❄ YELLOW SNOW WARS ❄

+

Snowmen. Yellow snowballs. No questions asked.

+ + +

+
+ WASD / arrows move  •  Mouse aim  •  Click / Space throw
+ Win rounds to earn ★ — first to 5 ★ takes the match.
+ Got 3 ❤. Yellow snow melts snowmen. Science. +
+
+
+ + + + diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..0c69c81 --- /dev/null +++ b/public/style.css @@ -0,0 +1,93 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } + +html, body { + height: 100%; + overflow: hidden; + background: radial-gradient(circle at 50% 30%, #17263f, #0a1120); + font-family: system-ui, 'Segoe UI', sans-serif; +} + +#game { + position: absolute; + border-radius: 6px; + box-shadow: 0 0 60px rgba(80, 140, 220, 0.25); + cursor: crosshair; +} + +#join-overlay { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(6, 12, 24, 0.72); + backdrop-filter: blur(4px); + z-index: 10; +} + +.panel { + background: linear-gradient(160deg, #101d33, #0b1424); + border: 1px solid #2b4a75; + border-radius: 14px; + padding: 36px 48px; + text-align: center; + color: #dce9fa; + box-shadow: 0 10px 50px rgba(0, 0, 0, 0.5); +} + +.panel h1 { + font-size: 32px; + letter-spacing: 2px; + color: #ffe135; + text-shadow: 0 2px 0 #7a5c00, 0 0 24px rgba(255, 225, 53, 0.35); +} + +.tag { + margin: 8px 0 22px; + color: #9db8d8; + font-style: italic; +} + +#name { + display: block; + margin: 0 auto 14px; + padding: 10px 14px; + font-size: 16px; + border-radius: 8px; + border: 1px solid #3a5c8c; + background: #0a1626; + color: #fff; + width: 250px; + text-align: center; + outline: none; +} +#name:focus { border-color: #ffe135; } + +#play { + padding: 12px 32px; + font-size: 17px; + font-weight: 700; + letter-spacing: 1px; + border: none; + border-radius: 8px; + background: linear-gradient(180deg, #ffe135, #f5b800); + color: #4a3600; + cursor: pointer; +} +#play:hover { filter: brightness(1.08); } +#play:disabled { opacity: 0.5; cursor: default; } + +#status { + min-height: 20px; + margin-top: 12px; + color: #ff9d9d; + font-size: 14px; +} + +.help { + margin-top: 18px; + font-size: 13px; + color: #8aa6cc; + line-height: 1.8; +} +.help b { color: #cfe2ff; } diff --git a/src/client/index.ts b/src/client/index.ts new file mode 100644 index 0000000..f1d40a5 --- /dev/null +++ b/src/client/index.ts @@ -0,0 +1,361 @@ +import * as C from '../shared/constants.js'; +import type { ServerMsg, Snapshot } from '../shared/protocol.js'; +import { generateObstacles, resolveCircleMovement } from '../shared/physics.js'; +import type { Obstacle } from '../shared/physics.js'; +import { drawFrame } from './render.js'; +import type { RenderPlayer, Toast, View } from './render.js'; +import { Particles, Snowfall } from './particles.js'; +import { initAudio, sfx } from './sfx.js'; + +const INTERP_TICKS = 3; // render ~100ms in the past for smooth interpolation + +// ---------- DOM ---------- +const canvas = document.getElementById('game') as HTMLCanvasElement; +const ctx = canvas.getContext('2d')!; +const overlay = document.getElementById('join-overlay')!; +const nameInput = document.getElementById('name') as HTMLInputElement; +const playBtn = document.getElementById('play') as HTMLButtonElement; +const statusEl = document.getElementById('status')!; + +let scale = 1; +function resize(): void { + const dpr = Math.min(2, window.devicePixelRatio || 1); + const w = window.innerWidth, h = window.innerHeight; + scale = Math.min(w / C.ARENA_W, h / C.ARENA_H); + canvas.width = Math.round(C.ARENA_W * scale * dpr); + canvas.height = Math.round(C.ARENA_H * scale * dpr); + canvas.style.width = `${C.ARENA_W * scale}px`; + canvas.style.height = `${C.ARENA_H * scale}px`; + canvas.style.left = `${(w - C.ARENA_W * scale) / 2}px`; + canvas.style.top = `${(h - C.ARENA_H * scale) / 2}px`; + ctx.setTransform(scale * dpr, 0, 0, scale * dpr, 0, 0); +} +window.addEventListener('resize', resize); +resize(); + +// ---------- input ---------- +const keys = { up: false, down: false, left: false, right: false, fire: false }; +const keyMap: Record = { + KeyW: 'up', ArrowUp: 'up', + KeyS: 'down', ArrowDown: 'down', + KeyA: 'left', ArrowLeft: 'left', + KeyD: 'right', ArrowRight: 'right', + Space: 'fire', +}; +window.addEventListener('keydown', (e) => { + const k = keyMap[e.code]; + if (k) { + keys[k] = true; + if (e.code === 'Space' || e.code.startsWith('Arrow')) e.preventDefault(); + } +}); +window.addEventListener('keyup', (e) => { + const k = keyMap[e.code]; + if (k) keys[k] = false; +}); +let mouseCX = 0, mouseCY = 0, mouseDown = false; +window.addEventListener('mousemove', (e) => { mouseCX = e.clientX; mouseCY = e.clientY; }); +canvas.addEventListener('mousedown', () => { mouseDown = true; initAudio(); }); +window.addEventListener('mouseup', () => { mouseDown = false; }); + +// ---------- state ---------- +let ws: WebSocket | null = null; +let myId = ''; +let joined = false; +let obstacles: Obstacle[] = []; +let snaps: Snapshot[] = []; +let latest: Snapshot | null = null; + +let selfX = 0, selfY = 0; // predicted position +let selfServerX = 0, selfServerY = 0; // last authoritative position +let aimAngle = 0; + +const flash = new Map(); +let shake = 0; +let vignette = 0; +let fightFlash = 0; +let ping = 0; +let lastCountdownNum = -1; +const toasts: Toast[] = []; +const particles = new Particles(); +const snowfall = new Snowfall(); +let timeSec = 0; + +function toast(text: string): void { + toasts.push({ text, ttl: 4 }); + if (toasts.length > 8) toasts.shift(); +} + +// ---------- networking ---------- +function connect(name: string): void { + if (ws) { ws.onclose = null; ws.close(); } + const proto = location.protocol === 'https:' ? 'wss' : 'ws'; + const room = new URLSearchParams(location.search).get('room') || 'main'; + ws = new WebSocket(`${proto}://${location.host}/ws?room=${encodeURIComponent(room)}`); + statusEl.textContent = 'Connecting…'; + + ws.onopen = () => { ws!.send(JSON.stringify({ t: 'join', name })); }; + ws.onerror = () => { statusEl.textContent = 'Connection failed — is the server running?'; }; + ws.onclose = () => { + if (joined) { + joined = false; + overlay.style.display = 'flex'; + statusEl.textContent = 'Disconnected from server.'; + playBtn.disabled = false; + } else if (!statusEl.textContent) { + statusEl.textContent = 'Could not reach server.'; + playBtn.disabled = false; + } + }; + ws.onmessage = (ev) => { + let msg: ServerMsg; + try { msg = JSON.parse(ev.data as string); } catch { return; } + if (msg.t === 'welcome') { + myId = msg.id; + obstacles = generateObstacles(msg.seed); + joined = true; + overlay.style.display = 'none'; + toast(`❄ Joined room "${msg.room}"`); + } else if (msg.t === 'full') { + statusEl.textContent = 'Room is full! Try another: ?room=party2'; + playBtn.disabled = false; + } else if (msg.t === 'pong') { + ping = Math.max(0, Date.now() - msg.ts); + } else if (msg.t === 'state') { + handleSnapshot(msg); + } + }; +} + +function handleSnapshot(s: Snapshot): void { + latest = s; + snaps.push(s); + if (snaps.length > 45) snaps.shift(); + + for (const e of s.events) { + switch (e.kind) { + case 'throw': + particles.puff(e.x, e.y, '#fff3b0', 5); + sfx.shoot(); + break; + case 'hit': + particles.burst(e.x, e.y, '#ffe135', 14, 170, 5, 0.5, 350); + sfx.hit(); + flash.set(e.id, 0.25); + if (e.id === myId) { shake = 9; vignette = 0.5; } + break; + case 'die': + particles.burst(e.x, e.y, '#ffffff', 26, 220, 7, 0.8, 140); + particles.burst(e.x, e.y, '#ffe135', 16, 180, 5, 0.7, 250); + sfx.die(); + if (e.id === myId) { shake = 15; vignette = 0.85; } + break; + case 'roundStart': + fightFlash = 1.2; + sfx.go(); + snapSelfToServer(s); + break; + case 'roundEnd': + sfx.roundWin(); + break; + case 'matchEnd': + sfx.matchWin(); + break; + case 'join': + toast(`❄ ${e.name} joined`); + sfx.join(); + break; + case 'leave': + toast(`💨 ${e.name} left`); + break; + } + } + + // countdown beeps + if (s.phase === 'countdown') { + const n = Math.ceil(s.phaseTime); + if (n !== lastCountdownNum && n > 0) { + lastCountdownNum = n; + sfx.count(); + } + } + + const me = s.players.find(p => p.id === myId); + if (me) { + selfServerX = me.x; + selfServerY = me.y; + const err = Math.hypot(me.x - selfX, me.y - selfY); + if (err > 90 || !me.alive) { + selfX = me.x; + selfY = me.y; + } + } +} + +function snapSelfToServer(s: Snapshot): void { + const me = s.players.find(p => p.id === myId); + if (me) { selfX = me.x; selfY = me.y; } +} + +// send inputs at 30 Hz +setInterval(() => { + if (ws && ws.readyState === WebSocket.OPEN && joined) { + ws.send(JSON.stringify({ + t: 'input', + up: keys.up, down: keys.down, left: keys.left, right: keys.right, + angle: aimAngle, + fire: mouseDown || keys.fire, + })); + } +}, 1000 / 30); + +// ping every 2 s +setInterval(() => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ t: 'ping', ts: Date.now() })); + } +}, 2000); + +// ---------- interpolation ---------- +function interpolated(): { players: RenderPlayer[]; balls: { id: number; x: number; y: number }[] } { + const players: RenderPlayer[] = []; + const balls: { id: number; x: number; y: number }[] = []; + if (snaps.length === 0) return { players, balls }; + + const newest = snaps[snaps.length - 1]; + const rt = newest.tick - INTERP_TICKS; + let s1 = snaps[0], s2 = newest; + for (let k = 0; k < snaps.length - 1; k++) { + if (snaps[k].tick <= rt && snaps[k + 1].tick >= rt) { + s1 = snaps[k]; + s2 = snaps[k + 1]; + break; + } + } + const span = Math.max(1, s2.tick - s1.tick); + const f = Math.min(1, Math.max(0, (rt - s1.tick) / span)); + + for (const p2 of s2.players) { + const p1 = s1.players.find(q => q.id === p2.id) ?? p2; + players.push({ + ...p2, + ix: p1.x + (p2.x - p1.x) * f, + iy: p1.y + (p2.y - p1.y) * f, + flash: flash.get(p2.id) ?? 0, + }); + } + for (const b2 of s2.snowballs) { + const b1 = s1.snowballs.find(q => q.id === b2.id) ?? b2; + balls.push({ id: b2.id, x: b1.x + (b2.x - b1.x) * f, y: b1.y + (b2.y - b1.y) * f }); + } + return { players, balls }; +} + +// ---------- main loop ---------- +let last = performance.now(); +function frame(now: number): void { + const dt = Math.min(0.05, (now - last) / 1000); + last = now; + timeSec += dt; + + // decay fx + shake = Math.max(0, shake - 30 * dt); + vignette = Math.max(0, vignette - 1.4 * dt); + fightFlash = Math.max(0, fightFlash - dt); + for (const [k, v] of flash) { + const nv = v - dt; + if (nv <= 0) flash.delete(k); else flash.set(k, nv); + } + for (let i = toasts.length - 1; i >= 0; i--) { + toasts[i].ttl -= dt; + if (toasts[i].ttl <= 0) toasts.splice(i, 1); + } + + // mouse -> world, aim + const rect = canvas.getBoundingClientRect(); + const mouseX = (mouseCX - rect.left) / scale; + const mouseY = (mouseCY - rect.top) / scale; + aimAngle = Math.atan2(mouseY - selfY, mouseX - selfX); + + // client-side prediction for our own snowman (same physics as server) + const me = latest?.players.find(p => p.id === myId); + if (me && me.alive && me.inRound && latest?.phase === 'round') { + const dx = (keys.right ? 1 : 0) - (keys.left ? 1 : 0); + const dy = (keys.down ? 1 : 0) - (keys.up ? 1 : 0); + const len = Math.hypot(dx, dy) || 1; + const res = resolveCircleMovement( + selfX + (dx / len) * C.PLAYER_SPEED * dt, + selfY + (dy / len) * C.PLAYER_SPEED * dt, + C.PLAYER_RADIUS, obstacles, + ); + selfX = res.x; + selfY = res.y; + const ex = selfServerX - selfX, ey = selfServerY - selfY; + if (Math.hypot(ex, ey) > 90) { + selfX = selfServerX; + selfY = selfServerY; + } else { + const k = Math.min(1, 8 * dt); + selfX += ex * k; + selfY += ey * k; + } + } else if (me) { + selfX = me.x; + selfY = me.y; + } + + particles.update(dt); + snowfall.update(dt, timeSec); + + // ---- render ---- + const { players, balls } = interpolated(); + const nameOf = (id: string | null) => + id ? latest?.players.find(p => p.id === id)?.name ?? 'Someone' : null; + + const view: View = { + time: timeSec, + phase: latest?.phase ?? 'lobby', + phaseTime: latest?.phaseTime ?? 0, + round: latest?.round ?? 0, + players, + balls, + obstacles, + selfId: myId, + selfX, selfY, + aimAngle, + mouseX, mouseY, + selfHp: me?.hp ?? 0, + selfCooldown: me?.cooldown ?? 0, + selfAlive: me?.alive ?? false, + selfInRound: me?.inRound ?? false, + roundWinnerName: nameOf(latest?.roundWinnerId ?? null), + matchWinnerName: nameOf(latest?.matchWinnerId ?? null), + ping, + vignette, + fightFlash, + joined, + }; + + ctx.save(); + if (shake > 0.2) { + ctx.translate((Math.random() - 0.5) * shake, (Math.random() - 0.5) * shake); + } + drawFrame(ctx, view, particles, snowfall, toasts); + ctx.restore(); + + requestAnimationFrame(frame); +} +requestAnimationFrame(frame); + +// ---------- join flow ---------- +playBtn.addEventListener('click', () => { + initAudio(); + const name = nameInput.value.trim() || `Snowman${Math.floor(Math.random() * 100)}`; + playBtn.disabled = true; + connect(name); +}); +nameInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') playBtn.click(); + e.stopPropagation(); +}); +nameInput.focus(); diff --git a/src/client/particles.ts b/src/client/particles.ts new file mode 100644 index 0000000..92be905 --- /dev/null +++ b/src/client/particles.ts @@ -0,0 +1,94 @@ +import { ARENA_W, ARENA_H } from '../shared/constants.js'; + +interface P { + x: number; y: number; vx: number; vy: number; + life: number; max: number; size: number; + color: string; grav: number; +} + +export class Particles { + private list: P[] = []; + + burst( + x: number, y: number, color: string, count: number, + speed: number, size: number, life: number, grav = 0, + ): void { + for (let i = 0; i < count; i++) { + const a = Math.random() * Math.PI * 2; + const s = speed * (0.3 + Math.random() * 0.7); + this.list.push({ + x, y, + vx: Math.cos(a) * s, + vy: Math.sin(a) * s, + life: life * (0.6 + Math.random() * 0.8), + max: life, + size: size * (0.6 + Math.random() * 0.8), + color, grav, + }); + } + } + + puff(x: number, y: number, color: string, count = 8): void { + this.burst(x, y, color, count, 60, 5, 0.5, -20); + } + + update(dt: number): void { + for (let i = this.list.length - 1; i >= 0; i--) { + const p = this.list[i]; + p.life -= dt; + if (p.life <= 0) { this.list.splice(i, 1); continue; } + p.vy += p.grav * dt; + p.vx *= 1 - 1.5 * dt; + p.x += p.vx * dt; + p.y += p.vy * dt; + } + } + + draw(ctx: CanvasRenderingContext2D): void { + for (const p of this.list) { + const a = Math.max(0, p.life / p.max); + ctx.globalAlpha = a; + ctx.fillStyle = p.color; + ctx.beginPath(); + ctx.arc(p.x, p.y, Math.max(0.5, p.size * a), 0, Math.PI * 2); + ctx.fill(); + } + ctx.globalAlpha = 1; + } +} + +/** Ambient drifting snowflakes in front of the scene. */ +export class Snowfall { + private flakes: { x: number; y: number; r: number; sp: number; ph: number }[] = []; + + constructor(n = 130) { + for (let i = 0; i < n; i++) { + this.flakes.push({ + x: Math.random() * ARENA_W, + y: Math.random() * ARENA_H, + r: 1 + Math.random() * 2.2, + sp: 22 + Math.random() * 46, + ph: Math.random() * Math.PI * 2, + }); + } + } + + update(dt: number, t: number): void { + for (const f of this.flakes) { + f.y += f.sp * dt; + f.x += Math.sin(t * 0.7 + f.ph) * 14 * dt; + if (f.y > ARENA_H + 5) { f.y = -5; f.x = Math.random() * ARENA_W; } + if (f.x < -5) f.x = ARENA_W + 5; + if (f.x > ARENA_W + 5) f.x = -5; + } + } + + draw(ctx: CanvasRenderingContext2D): void { + ctx.fillStyle = 'rgba(255,255,255,0.75)'; + for (const f of this.flakes) { + ctx.beginPath(); + ctx.arc(f.x, f.y, f.r, 0, Math.PI * 2); + ctx.fill(); + } + } +} diff --git a/src/client/render.ts b/src/client/render.ts new file mode 100644 index 0000000..63d69bd --- /dev/null +++ b/src/client/render.ts @@ -0,0 +1,533 @@ +import * as C from '../shared/constants.js'; +import type { Phase, PlayerState } from '../shared/protocol.js'; +import type { Obstacle } from '../shared/physics.js'; +import type { Particles, Snowfall } from './particles.js'; + +export interface RenderPlayer extends PlayerState { + ix: number; // interpolated x + iy: number; // interpolated y + flash: number; // hit-flash timer (client-side) +} + +export interface View { + time: number; + phase: Phase; + phaseTime: number; + round: number; + players: RenderPlayer[]; + balls: { id: number; x: number; y: number }[]; + obstacles: Obstacle[]; + selfId: string; + selfX: number; + selfY: number; + aimAngle: number; + mouseX: number; + mouseY: number; + selfHp: number; + selfCooldown: number; + selfAlive: boolean; + selfInRound: boolean; + roundWinnerName: string | null; + matchWinnerName: string | null; + ping: number; + vignette: number; + fightFlash: number; + joined: boolean; +} + +export interface Toast { text: string; ttl: number } + +const css = (n: number) => '#' + n.toString(16).padStart(6, '0'); + +// ---------- ground texture (generated once, kept static) ---------- +let groundDots: { x: number; y: number; r: number; a: number }[] | null = null; +function getGroundDots(): { x: number; y: number; r: number; a: number }[] { + if (!groundDots) { + groundDots = []; + for (let i = 0; i < 260; i++) { + groundDots.push({ + x: Math.random() * C.ARENA_W, + y: Math.random() * C.ARENA_H, + r: 1 + Math.random() * 3.5, + a: 0.04 + Math.random() * 0.08, + }); + } + } + return groundDots; +} + +function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); +} + +function text( + ctx: CanvasRenderingContext2D, str: string, x: number, y: number, + size: number, color: string, align: CanvasTextAlign = 'center', outline = true, +): void { + ctx.font = `bold ${size}px system-ui, 'Segoe UI', sans-serif`; + ctx.textAlign = align; + ctx.textBaseline = 'middle'; + if (outline) { + ctx.lineWidth = Math.max(2, size / 6); + ctx.strokeStyle = 'rgba(10,20,40,0.85)'; + ctx.strokeText(str, x, y); + } + ctx.fillStyle = color; + ctx.fillText(str, x, y); +} + +function heart(ctx: CanvasRenderingContext2D, x: number, y: number, s: number, color: string): void { + ctx.fillStyle = color; + ctx.beginPath(); + ctx.moveTo(x, y + s * 0.9); + ctx.bezierCurveTo(x - s * 1.2, y + s * 0.1, x - s * 0.6, y - s * 0.85, x, y - s * 0.15); + ctx.bezierCurveTo(x + s * 0.6, y - s * 0.85, x + s * 1.2, y + s * 0.1, x, y + s * 0.9); + ctx.fill(); +} + +// ---------- obstacles ---------- + +function drawObstacle(ctx: CanvasRenderingContext2D, ob: Obstacle): void { + if (ob.kind === 'circle') { + const { x, y, r } = ob; + ctx.fillStyle = 'rgba(20,40,70,0.12)'; + ctx.beginPath(); + ctx.ellipse(x, y + r * 0.6, r * 1.05, r * 0.35, 0, 0, Math.PI * 2); + ctx.fill(); + + if (ob.style === 0) { + // pine tree: trunk + three green tiers with snow caps + ctx.fillStyle = '#6b4a2f'; + ctx.fillRect(x - r * 0.13, y - r * 0.1, r * 0.26, r * 0.75); + const tiers = [ + { w: r * 1.9, bottom: y + r * 0.35, h: r * 0.95 }, + { w: r * 1.5, bottom: y - r * 0.25, h: r * 0.85 }, + { w: r * 1.05, bottom: y - r * 0.78, h: r * 0.8 }, + ]; + tiers.forEach((t, i) => { + ctx.fillStyle = i % 2 === 0 ? '#2f6b4c' : '#3a7d5a'; + ctx.beginPath(); + ctx.moveTo(x, t.bottom - t.h); + ctx.lineTo(x - t.w / 2, t.bottom); + ctx.lineTo(x + t.w / 2, t.bottom); + ctx.closePath(); + ctx.fill(); + // snow cap on the apex + ctx.fillStyle = 'rgba(255,255,255,0.9)'; + ctx.beginPath(); + ctx.moveTo(x, t.bottom - t.h); + ctx.lineTo(x - t.w * 0.16, t.bottom - t.h * 0.68); + ctx.lineTo(x + t.w * 0.16, t.bottom - t.h * 0.68); + ctx.closePath(); + ctx.fill(); + }); + } else { + // rock with snowy top + const g = ctx.createRadialGradient(x - r * 0.3, y - r * 0.3, r * 0.2, x, y, r); + g.addColorStop(0, '#aab6c4'); + g.addColorStop(1, '#6e7a89'); + ctx.fillStyle = g; + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = 'rgba(50,60,75,0.6)'; + ctx.lineWidth = 2; + ctx.stroke(); + ctx.fillStyle = 'rgba(255,255,255,0.85)'; + ctx.beginPath(); + ctx.ellipse(x, y - r * 0.45, r * 0.72, r * 0.35, 0, 0, Math.PI * 2); + ctx.fill(); + } + } else { + const { x, y, w, h } = ob; + ctx.fillStyle = 'rgba(20,40,70,0.12)'; + ctx.beginPath(); + ctx.ellipse(x + w / 2, y + h + 4, w * 0.55, 7, 0, 0, Math.PI * 2); + ctx.fill(); + + if (ob.style === 2) { + // ice block + const g = ctx.createLinearGradient(x, y, x, y + h); + g.addColorStop(0, 'rgba(195,230,252,0.97)'); + g.addColorStop(1, 'rgba(140,190,235,0.97)'); + rrect(ctx, x, y, w, h, 8); + ctx.fillStyle = g; + ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.85)'; + ctx.lineWidth = 2; + ctx.stroke(); + ctx.strokeStyle = 'rgba(255,255,255,0.5)'; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.moveTo(x + w * 0.15, y + h * 0.75); + ctx.lineTo(x + w * 0.45, y + h * 0.2); + ctx.moveTo(x + w * 0.55, y + h * 0.8); + ctx.lineTo(x + w * 0.85, y + h * 0.25); + ctx.stroke(); + } else { + // snow mound + const g = ctx.createLinearGradient(x, y, x, y + h); + g.addColorStop(0, '#ffffff'); + g.addColorStop(1, '#cfdff2'); + ctx.fillStyle = g; + ctx.beginPath(); + ctx.ellipse(x + w / 2, y + h / 2, w / 2, h / 2, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = 'rgba(140,170,210,0.5)'; + ctx.lineWidth = 2; + ctx.stroke(); + } + } +} + +// ---------- snowballs (yellow, of course) ---------- + +function drawSnowball(ctx: CanvasRenderingContext2D, x: number, y: number, time: number): void { + const pulse = 1 + Math.sin(time * 25 + x) * 0.06; + const r = C.SNOWBALL_RADIUS * pulse; + const g = ctx.createRadialGradient(x - 2, y - 2, 1, x, y, r + 2); + g.addColorStop(0, '#fff9d6'); + g.addColorStop(0.55, '#ffe135'); + g.addColorStop(1, '#f0b400'); + ctx.fillStyle = g; + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = 'rgba(160,110,0,0.65)'; + ctx.lineWidth = 1.5; + ctx.stroke(); +} + +// ---------- snowmen ---------- + +function drawPuddle(ctx: CanvasRenderingContext2D, p: RenderPlayer): void { + const { ix: x, iy: y } = p; + ctx.fillStyle = 'rgba(205,228,248,0.85)'; + ctx.beginPath(); + ctx.ellipse(x, y + 14, 26, 9, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = 'rgba(150,185,225,0.7)'; + ctx.lineWidth = 1.5; + ctx.stroke(); + // leftover carrot + ctx.fillStyle = '#e8862d'; + ctx.beginPath(); + ctx.moveTo(x + 6, y + 11); + ctx.lineTo(x + 18, y + 14); + ctx.lineTo(x + 6, y + 17); + ctx.closePath(); + ctx.fill(); + // coal bits + ctx.fillStyle = '#2b2b2b'; + ctx.beginPath(); ctx.arc(x - 8, y + 13, 2.2, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(x - 14, y + 16, 2.2, 0, Math.PI * 2); ctx.fill(); + // scarf scrap + ctx.fillStyle = css(C.PLAYER_COLORS[p.color]); + ctx.fillRect(x - 2, y + 18, 12, 4); +} + +function drawSnowman( + ctx: CanvasRenderingContext2D, p: RenderPlayer, + x: number, y: number, angle: number, time: number, +): void { + const color = css(C.PLAYER_COLORS[p.color]); + + // shadow + ctx.fillStyle = 'rgba(20,40,70,0.15)'; + ctx.beginPath(); + ctx.ellipse(x, y + 24, 20, 7, 0, 0, Math.PI * 2); + ctx.fill(); + + const snowBall = (bx: number, by: number, br: number) => { + const g = ctx.createRadialGradient(bx - br * 0.35, by - br * 0.4, br * 0.2, bx, by, br); + g.addColorStop(0, '#ffffff'); + g.addColorStop(1, '#c9dcf2'); + ctx.fillStyle = g; + ctx.beginPath(); + ctx.arc(bx, by, br, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = 'rgba(110,145,190,0.45)'; + ctx.lineWidth = 1.5; + ctx.stroke(); + }; + + snowBall(x, y + 8, 17); + snowBall(x, y - 11, 13.5); + snowBall(x, y - 27, 10.5); + + // coal buttons + ctx.fillStyle = '#2b2b2b'; + ctx.beginPath(); ctx.arc(x + 1, y - 15, 2, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(x - 1, y - 7, 2, 0, Math.PI * 2); ctx.fill(); + + // stick arms reaching toward the aim direction + const ax = Math.cos(angle), ay = Math.sin(angle); + ctx.strokeStyle = '#6b4a2f'; + ctx.lineWidth = 3; + ctx.lineCap = 'round'; + ctx.beginPath(); + ctx.moveTo(x - 10, y - 12); + ctx.lineTo(x - 10 + ax * 15 - ay * 4, y - 12 + ay * 15 + ax * 4); + ctx.moveTo(x + 10, y - 12); + ctx.lineTo(x + 10 + ax * 15 + ay * 4, y - 12 + ay * 15 - ax * 4); + ctx.stroke(); + + // eyes look toward the aim + ctx.fillStyle = '#1d1d1d'; + ctx.beginPath(); ctx.arc(x + ax * 3 - ay * 4, y - 29 + ay * 3 + ax * 0, 1.7, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(x + ax * 3 + ay * 4, y - 29 + ay * 3, 1.7, 0, Math.PI * 2); ctx.fill(); + + // carrot nose points at the aim + const nx = x + ax * 8, ny = y - 27 + ay * 8; + const px = -ay, py = ax; + ctx.fillStyle = '#e8862d'; + ctx.beginPath(); + ctx.moveTo(x + ax * 16, y - 27 + ay * 16); + ctx.lineTo(nx + px * 3, ny + py * 3); + ctx.lineTo(nx - px * 3, ny - py * 3); + ctx.closePath(); + ctx.fill(); + + // scarf (player color) + flapping tail + ctx.fillStyle = color; + ctx.beginPath(); + ctx.ellipse(x, y - 19, 11.5, 4.5, 0, 0, Math.PI * 2); + ctx.fill(); + const flap = Math.sin(time * 6 + x) * 0.25; + ctx.save(); + ctx.translate(x - 9, y - 18); + ctx.rotate(Math.PI / 2 + flap); + ctx.fillRect(-3.5, 0, 7, 12); + ctx.restore(); + + // readied yellow snowball in hand + if (p.alive && p.cooldown <= 0.05 && p.inRound) { + const hx = x + ax * 24, hy = y - 12 + ay * 20; + ctx.fillStyle = '#ffe135'; + ctx.beginPath(); + ctx.arc(hx, hy, 5.5, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = 'rgba(160,110,0,0.7)'; + ctx.lineWidth = 1.2; + ctx.stroke(); + } + + // hit flash + if (p.flash > 0) { + ctx.globalAlpha = Math.min(0.7, p.flash * 3); + ctx.fillStyle = '#ff5b4d'; + ctx.beginPath(); + ctx.arc(x, y - 10, 26, 0, Math.PI * 2); + ctx.fill(); + ctx.globalAlpha = 1; + } + + // name + hp + text(ctx, p.name, x, y - 48, 12, color); + for (let i = 0; i < C.MAX_HP; i++) { + const hx = x + (i - (C.MAX_HP - 1) / 2) * 14; + heart(ctx, hx, y - 62, 5.5, i < p.hp ? '#ff4d5e' : 'rgba(60,70,90,0.45)'); + } +} + +// ---------- HUD ---------- + +function drawScoreboard(ctx: CanvasRenderingContext2D, v: View): void { + const rows = [...v.players].sort((a, b) => b.score - a.score || b.kills - a.kills); + const w = 250; + const h = rows.length * 22 + 46; + const x = C.ARENA_W - w - 12; + const y = 12; + ctx.fillStyle = 'rgba(8,16,32,0.55)'; + rrect(ctx, x, y, w, h, 10); + ctx.fill(); + text(ctx, `MATCH — first to ${C.POINTS_TO_WIN} ★`, x + w / 2, y + 17, 13, '#ffe135'); + rows.forEach((p, i) => { + const ry = y + 38 + i * 22; + if (p.id === v.selfId) { + ctx.fillStyle = 'rgba(255,255,255,0.10)'; + rrect(ctx, x + 6, ry - 9, w - 12, 18, 6); + ctx.fill(); + } + ctx.fillStyle = css(C.PLAYER_COLORS[p.color]); + ctx.beginPath(); + ctx.arc(x + 18, ry, 6, 0, Math.PI * 2); + ctx.fill(); + const status = p.inRound ? (p.alive ? '' : ' 💧') : ' ⏳'; + text(ctx, p.name.slice(0, 12) + status, x + 30, ry, 12, '#e8f1fb', 'left', false); + text(ctx, `⚔${p.kills}`, x + w - 78, ry, 12, '#ffb3b3', 'left', false); + text(ctx, `★${p.score}`, x + w - 40, ry, 12, '#ffe135', 'left', false); + }); +} + +function drawTopBar(ctx: CanvasRenderingContext2D, v: View): void { + const w = 240, h = 46; + const x = C.ARENA_W / 2 - w / 2, y = 10; + ctx.fillStyle = 'rgba(8,16,32,0.55)'; + rrect(ctx, x, y, w, h, 10); + ctx.fill(); + const label = v.phase === 'round' + ? `⏱ ${Math.ceil(v.phaseTime)}s` + : v.phase === 'countdown' + ? 'GET READY' + : v.phase === 'lobby' + ? 'LOBBY' + : '—'; + text(ctx, `ROUND ${Math.max(1, v.round)}`, C.ARENA_W / 2, y + 14, 12, '#9db8d8'); + text(ctx, label, C.ARENA_W / 2, y + 32, 16, '#ffffff'); +} + +function drawBanner(ctx: CanvasRenderingContext2D, v: View): void { + const cx = C.ARENA_W / 2, cy = C.ARENA_H / 2; + switch (v.phase) { + case 'lobby': + text(ctx, 'WAITING FOR PLAYERS…', cx, cy - 60, 34, '#ffffff'); + text(ctx, 'need at least 2 snowmen on the battlefield', cx, cy - 24, 16, '#9db8d8'); + break; + case 'countdown': { + const n = Math.ceil(v.phaseTime); + text(ctx, String(n), cx, cy - 70, 96, '#ffe135'); + text(ctx, 'GET READY', cx, cy + 6, 22, '#ffffff'); + break; + } + case 'roundEnd': + if (v.roundWinnerName) { + text(ctx, `${v.roundWinnerName} WINS THE ROUND!`, cx, 120, 36, '#ffe135'); + text(ctx, '+1 ★', cx, 158, 22, '#ffffff'); + } else { + text(ctx, 'ROUND DRAW — no points', cx, 120, 30, '#9db8d8'); + } + break; + case 'matchEnd': + text(ctx, `🏆 ${v.matchWinnerName ?? ''} WINS THE MATCH! 🏆`, cx, cy - 60, 42, '#ffe135'); + text(ctx, 'new match starting soon…', cx, cy - 12, 18, '#ffffff'); + break; + } + + if (v.fightFlash > 0) { + ctx.globalAlpha = Math.min(1, v.fightFlash); + text(ctx, 'THROW!', cx, 200, 58, '#ffe135'); + ctx.globalAlpha = 1; + } + + // personal status + if (v.phase === 'round') { + if (v.selfInRound && !v.selfAlive) { + text(ctx, 'YOU MELTED! 💧 spectating…', cx, C.ARENA_H - 110, 20, '#ffb3b3'); + } else if (!v.selfInRound) { + text(ctx, 'You joined mid-round — you\'ll spawn next round!', cx, C.ARENA_H - 110, 18, '#9db8d8'); + } + } +} + +function drawCrosshair(ctx: CanvasRenderingContext2D, v: View): void { + const x = v.mouseX, y = v.mouseY; + ctx.strokeStyle = 'rgba(20,40,70,0.8)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(x, y, 10, 0, Math.PI * 2); + ctx.stroke(); + ctx.fillStyle = 'rgba(20,40,70,0.8)'; + ctx.beginPath(); + ctx.arc(x, y, 2, 0, Math.PI * 2); + ctx.fill(); + if (v.selfCooldown > 0.02) { + const f = 1 - v.selfCooldown / C.THROW_COOLDOWN; + ctx.strokeStyle = '#f0b400'; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.arc(x, y, 14, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * f); + ctx.stroke(); + } +} + +// ---------- frame ---------- + +export function drawFrame( + ctx: CanvasRenderingContext2D, v: View, + particles: Particles, snowfall: Snowfall, toasts: Toast[], +): void { + // sky / snow ground + const g = ctx.createLinearGradient(0, 0, 0, C.ARENA_H); + g.addColorStop(0, '#e9f2fc'); + g.addColorStop(1, '#d2e1f2'); + ctx.fillStyle = g; + ctx.fillRect(-60, -60, C.ARENA_W + 120, C.ARENA_H + 120); + + ctx.fillStyle = '#8fb0d8'; + for (const d of getGroundDots()) { + ctx.globalAlpha = d.a; + ctx.beginPath(); + ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2); + ctx.fill(); + } + ctx.globalAlpha = 1; + + // icy arena border + ctx.strokeStyle = 'rgba(120,160,210,0.9)'; + ctx.lineWidth = 6; + ctx.strokeRect(3, 3, C.ARENA_W - 6, C.ARENA_H - 6); + + for (const ob of v.obstacles) drawObstacle(ctx, ob); + + // melted snowmen under everything else + for (const p of v.players) { + if (p.inRound && !p.alive) drawPuddle(ctx, p); + } + + for (const b of v.balls) drawSnowball(ctx, b.x, b.y, v.time); + + // depth-sorted players (self drawn at predicted position) + const sorted = [...v.players].sort((a, b) => a.iy - b.iy); + for (const p of sorted) { + if (!p.alive) continue; + const isSelf = p.id === v.selfId; + const x = isSelf ? v.selfX : p.ix; + const y = isSelf ? v.selfY : p.iy; + const angle = isSelf ? v.aimAngle : p.angle; + drawSnowman(ctx, p, x, y, angle, v.time); + } + + particles.draw(ctx); + snowfall.draw(ctx); + + // red vignette when hit + if (v.vignette > 0) { + const rg = ctx.createRadialGradient( + C.ARENA_W / 2, C.ARENA_H / 2, C.ARENA_H / 3, + C.ARENA_W / 2, C.ARENA_H / 2, C.ARENA_H / 1.1, + ); + rg.addColorStop(0, 'rgba(255,40,40,0)'); + rg.addColorStop(1, `rgba(255,40,40,${Math.min(0.5, v.vignette)})`); + ctx.fillStyle = rg; + ctx.fillRect(0, 0, C.ARENA_W, C.ARENA_H); + } + + // ---- HUD ---- + drawTopBar(ctx, v); + drawScoreboard(ctx, v); + drawBanner(ctx, v); + + // self HP, bottom-left + if (v.joined) { + for (let i = 0; i < C.MAX_HP; i++) { + heart(ctx, 30 + i * 28, C.ARENA_H - 32, 11, i < v.selfHp ? '#ff4d5e' : 'rgba(60,70,90,0.4)'); + } + text(ctx, `ping ${v.ping}ms`, C.ARENA_W - 20, C.ARENA_H - 22, 11, '#7c93b5', 'right', false); + } + + // toast feed, top-left + toasts.slice(-5).forEach((t, i) => { + ctx.globalAlpha = Math.min(1, t.ttl); + text(ctx, t.text, 16, 24 + i * 20, 13, '#dce9fa', 'left', false); + ctx.globalAlpha = 1; + }); + + if (v.joined && v.selfAlive && v.selfInRound) drawCrosshair(ctx, v); +} diff --git a/src/client/sfx.ts b/src/client/sfx.ts new file mode 100644 index 0000000..7aa7ab9 --- /dev/null +++ b/src/client/sfx.ts @@ -0,0 +1,67 @@ +// All sound effects are synthesized with WebAudio — no audio files needed. + +let ac: AudioContext | null = null; +let master: GainNode | null = null; + +/** Must be called from a user gesture (click) at least once. */ +export function initAudio(): void { + try { + if (!ac) { + const AC = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext; + ac = new AC(); + master = ac.createGain(); + master.gain.value = 0.35; + master.connect(ac.destination); + } + if (ac.state === 'suspended') void ac.resume(); + } catch { /* audio unavailable — game still works */ } +} + +function tone(freq: number, freqEnd: number, dur: number, type: OscillatorType, vol: number, delay = 0): void { + if (!ac || !master) return; + const t0 = ac.currentTime + delay; + const o = ac.createOscillator(); + const g = ac.createGain(); + o.type = type; + o.frequency.setValueAtTime(freq, t0); + o.frequency.exponentialRampToValueAtTime(Math.max(1, freqEnd), t0 + dur); + g.gain.setValueAtTime(0.0001, t0); + g.gain.exponentialRampToValueAtTime(vol, t0 + 0.012); + g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); + o.connect(g); + g.connect(master); + o.start(t0); + o.stop(t0 + dur + 0.05); +} + +function noise(dur: number, vol: number, filterFreq: number, filterEnd?: number, delay = 0): void { + if (!ac || !master) return; + const t0 = ac.currentTime + delay; + const len = Math.max(1, Math.floor(dur * ac.sampleRate)); + const buf = ac.createBuffer(1, len, ac.sampleRate); + const d = buf.getChannelData(0); + for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / len); + const src = ac.createBufferSource(); + src.buffer = buf; + const f = ac.createBiquadFilter(); + f.type = 'bandpass'; + f.frequency.setValueAtTime(filterFreq, t0); + if (filterEnd) f.frequency.exponentialRampToValueAtTime(filterEnd, t0 + dur); + const g = ac.createGain(); + g.gain.value = vol; + src.connect(f); + f.connect(g); + g.connect(master); + src.start(t0); +} + +export const sfx = { + shoot(): void { noise(0.12, 0.5, 1800, 500); }, + hit(): void { tone(180, 70, 0.15, 'sine', 0.6); noise(0.08, 0.3, 300); }, + die(): void { tone(300, 60, 0.4, 'sawtooth', 0.35); noise(0.3, 0.35, 800, 150); }, + count(): void { tone(880, 880, 0.08, 'square', 0.22); }, + go(): void { tone(1174, 1174, 0.18, 'square', 0.28); }, + roundWin(): void { [523, 659, 784].forEach((f, i) => tone(f, f, 0.12, 'triangle', 0.4, i * 0.1)); }, + matchWin(): void { [523, 659, 784, 1046, 1318].forEach((f, i) => tone(f, f, 0.16, 'triangle', 0.4, i * 0.09)); }, + join(): void { tone(660, 990, 0.09, 'sine', 0.18); }, +}; diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..97915c4 --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,79 @@ +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { WebSocketServer } from 'ws'; +import { Room } from './room.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PUBLIC_DIR = path.resolve(__dirname, '../../public'); +const PORT = Number(process.env.PORT || 3000); + +const MIME: Record = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', +}; + +const server = http.createServer((req, res) => { + let urlPath = (req.url || '/').split('?')[0]; + if (urlPath === '/') urlPath = '/index.html'; + const file = path.normalize(path.join(PUBLIC_DIR, urlPath)); + if (!file.startsWith(PUBLIC_DIR)) { + res.writeHead(403); + res.end(); + return; + } + fs.readFile(file, (err, data) => { + if (err) { + res.writeHead(404); + res.end('not found'); + return; + } + res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream' }); + res.end(data); + }); +}); + +const wss = new WebSocketServer({ server, path: '/ws' }); +const rooms = new Map(); + +wss.on('connection', (ws, req) => { + (ws as unknown as { isAlive: boolean }).isAlive = true; + ws.on('pong', () => { (ws as unknown as { isAlive: boolean }).isAlive = true; }); + + const url = new URL(req.url || '/', 'http://localhost'); + const roomId = (url.searchParams.get('room') || 'main').replace(/[^\w-]/g, '').slice(0, 24) || 'main'; + + let room = rooms.get(roomId); + if (!room) { + const seed = (Math.random() * 2 ** 31) | 0; + room = new Room(roomId, seed, (id) => { + rooms.delete(id); + console.log(`[${id}] room closed (empty)`); + }); + rooms.set(roomId, room); + console.log(`[${roomId}] room created (seed ${seed})`); + } + room.attach(ws); +}); + +// Reap dead connections. +setInterval(() => { + for (const ws of wss.clients) { + const w = ws as unknown as { isAlive: boolean; terminate: () => void; ping: () => void }; + if (w.isAlive === false) { w.terminate(); continue; } + w.isAlive = false; + w.ping(); + } +}, 15000); + +server.listen(PORT, () => { + console.log(`❄ Yellow Snow Wars ❄`); + console.log(` http://localhost:${PORT}`); + console.log(` (open in two browser tabs to play, share your LAN IP for friends)`); +}); diff --git a/src/server/room.ts b/src/server/room.ts new file mode 100644 index 0000000..32364d5 --- /dev/null +++ b/src/server/room.ts @@ -0,0 +1,369 @@ +import { WebSocket } from 'ws'; +import * as C from '../shared/constants.js'; +import { + ClientMsg, GameEvent, Phase, PlayerState, ServerMsg, Snapshot, SnowballState, +} from '../shared/protocol.js'; +import { + circleHitsObstacle, generateObstacles, mulberry32, Obstacle, + resolveCircleMovement, sampleSpawns, +} from '../shared/physics.js'; + +interface ServerSnowball extends SnowballState { life: number } + +interface PlayerInput { + up: boolean; down: boolean; left: boolean; right: boolean; + angle: number; fire: boolean; +} + +interface ServerPlayer extends PlayerState { + ws: WebSocket; + input: PlayerInput; +} + +let nextPlayerNum = 1; +const r2 = (v: number) => Math.round(v * 100) / 100; + +/** + * One room = one ongoing match on its own battlefield. + * Phase machine: lobby -> countdown -> round -> roundEnd -> (countdown | matchEnd) + */ +export class Room { + players = new Map(); + obstacles: Obstacle[]; + snowballs: ServerSnowball[] = []; + events: GameEvent[] = []; + phase: Phase = 'lobby'; + phaseTime = 0; + round = 0; + tickN = 0; + roundWinnerId: string | null = null; + matchWinnerId: string | null = null; + ballId = 1; + private colorUse = new Array(C.PLAYER_COLORS.length).fill(0); + private timer: ReturnType; + + constructor( + public id: string, + public seed: number, + private onEmpty: (id: string) => void, + ) { + this.obstacles = generateObstacles(seed); + this.timer = setInterval(() => this.tick(), 1000 / C.TICK_RATE); + } + + // ---------- connection lifecycle ---------- + + attach(ws: WebSocket): void { + let pid: string | null = null; + ws.on('message', (raw) => { + let msg: ClientMsg; + try { msg = JSON.parse(raw.toString()); } catch { return; } + if (!msg || typeof msg !== 'object' || !('t' in msg)) return; + + if (msg.t === 'join' && !pid) { + pid = this.addPlayer(ws, typeof msg.name === 'string' ? msg.name : ''); + if (!pid) ws.close(); + } else if (msg.t === 'input' && pid) { + const p = this.players.get(pid); + if (p) { + p.input.up = !!msg.up; + p.input.down = !!msg.down; + p.input.left = !!msg.left; + p.input.right = !!msg.right; + p.input.angle = Number.isFinite(msg.angle) ? msg.angle : 0; + p.input.fire = !!msg.fire; + } + } else if (msg.t === 'ping') { + this.send(ws, { t: 'pong', ts: msg.ts }); + } + }); + ws.on('close', () => { if (pid) this.removePlayer(pid); }); + ws.on('error', () => { /* ignore */ }); + } + + private addPlayer(ws: WebSocket, rawName: string): string | null { + if (this.players.size >= C.MAX_PLAYERS) { + this.send(ws, { t: 'full' }); + return null; + } + const id = 'p' + nextPlayerNum++ + '_' + Math.random().toString(36).slice(2, 7); + let name = (rawName || '').trim().slice(0, 14) || 'Snowman'; + const base = name; + let n = 2; + while ([...this.players.values()].some(p => p.name === name)) name = `${base}${n++}`; + + let ci = 0; + for (let i = 1; i < this.colorUse.length; i++) { + if (this.colorUse[i] < this.colorUse[ci]) ci = i; + } + this.colorUse[ci]++; + + const rng = mulberry32((Math.random() * 1e9) | 0); + const spawn = sampleSpawns(1, this.obstacles, rng)[0]; + + const p: ServerPlayer = { + id, name, color: ci, + x: spawn.x, y: spawn.y, angle: 0, + hp: 0, alive: false, inRound: false, + kbx: 0, kby: 0, score: 0, kills: 0, cooldown: 0, + ws, input: { up: false, down: false, left: false, right: false, angle: 0, fire: false }, + }; + this.players.set(id, p); + this.send(ws, { t: 'welcome', id, seed: this.seed, room: this.id }); + this.events.push({ kind: 'join', name }); + console.log(`[${this.id}] ${name} joined (${this.players.size} players)`); + return id; + } + + private removePlayer(id: string): void { + const p = this.players.get(id); + if (!p) return; + this.colorUse[p.color]--; + this.players.delete(id); + this.events.push({ kind: 'leave', name: p.name }); + console.log(`[${this.id}] ${p.name} left (${this.players.size} players)`); + if (this.players.size === 0) { + clearInterval(this.timer); + this.onEmpty(this.id); + } + } + + private send(ws: WebSocket, msg: ServerMsg): void { + if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg)); + } + + // ---------- phase machine ---------- + + private tick(): void { + const dt = 1 / C.TICK_RATE; + this.tickN++; + + switch (this.phase) { + case 'lobby': + if (this.players.size >= 2) { + this.phase = 'countdown'; + this.phaseTime = C.COUNTDOWN_TIME; + } + break; + + case 'countdown': + this.phaseTime -= dt; + if (this.players.size < 2) { + this.phase = 'lobby'; + } else if (this.phaseTime <= 0) { + this.startRound(); + } + break; + + case 'round': + this.simulate(dt); + break; + + case 'roundEnd': + this.phaseTime -= dt; + if (this.phaseTime <= 0) { + if (this.players.size >= 2) { + this.phase = 'countdown'; + this.phaseTime = C.COUNTDOWN_TIME; + } else { + this.phase = 'lobby'; + } + } + break; + + case 'matchEnd': + this.phaseTime -= dt; + if (this.phaseTime <= 0) { + for (const p of this.players.values()) { p.score = 0; p.kills = 0; } + this.matchWinnerId = null; + this.round = 0; + this.phase = this.players.size >= 2 ? 'countdown' : 'lobby'; + this.phaseTime = C.COUNTDOWN_TIME; + } + break; + } + + this.broadcast(); + } + + private startRound(): void { + this.round++; + const rng = mulberry32(this.seed + this.round * 7919); + const arr = [...this.players.values()]; + const spawns = sampleSpawns(arr.length, this.obstacles, rng); + arr.forEach((p, i) => { + p.x = spawns[i].x; + p.y = spawns[i].y; + p.hp = C.MAX_HP; + p.alive = true; + p.inRound = true; + p.kbx = 0; p.kby = 0; + p.cooldown = 0.6; // tiny grace period before first throw + }); + this.snowballs = []; + this.roundWinnerId = null; + this.phase = 'round'; + this.phaseTime = C.ROUND_TIME; + this.events.push({ kind: 'roundStart', round: this.round }); + } + + private endRound(winnerId: string | null): void { + this.phase = 'roundEnd'; + this.phaseTime = C.ROUND_END_TIME; + this.roundWinnerId = winnerId; + this.snowballs = []; + + let matchWinner: string | null = null; + if (winnerId) { + const w = this.players.get(winnerId); + if (w) { + w.score++; + if (w.score >= C.POINTS_TO_WIN) { + matchWinner = winnerId; + this.matchWinnerId = winnerId; + } + } + } + this.events.push({ kind: 'roundEnd', winnerId }); + if (matchWinner) { + this.phase = 'matchEnd'; + this.phaseTime = C.MATCH_END_TIME; + this.events.push({ kind: 'matchEnd', winnerId: matchWinner }); + } + } + + // ---------- combat simulation ---------- + + private simulate(dt: number): void { + this.phaseTime -= dt; + + // players + for (const p of this.players.values()) { + if (!p.alive || !p.inRound) continue; + const inp = p.input; + let dx = (inp.right ? 1 : 0) - (inp.left ? 1 : 0); + let dy = (inp.down ? 1 : 0) - (inp.up ? 1 : 0); + const len = Math.hypot(dx, dy) || 1; + const vx = (dx / len) * C.PLAYER_SPEED + p.kbx; + const vy = (dy / len) * C.PLAYER_SPEED + p.kby; + const res = resolveCircleMovement(p.x + vx * dt, p.y + vy * dt, C.PLAYER_RADIUS, this.obstacles); + p.x = res.x; + p.y = res.y; + const decay = Math.max(0, 1 - 8 * dt); + p.kbx *= decay; + p.kby *= decay; + p.angle = inp.angle; + p.cooldown = Math.max(0, p.cooldown - dt); + + if (inp.fire && p.cooldown <= 0) { + p.cooldown = C.THROW_COOLDOWN; + const a = inp.angle; + const bx = p.x + Math.cos(a) * (C.PLAYER_RADIUS + C.SNOWBALL_RADIUS + 2); + const by = p.y + Math.sin(a) * (C.PLAYER_RADIUS + C.SNOWBALL_RADIUS + 2); + this.snowballs.push({ + id: this.ballId++, owner: p.id, x: bx, y: by, + vx: Math.cos(a) * C.SNOWBALL_SPEED, vy: Math.sin(a) * C.SNOWBALL_SPEED, + life: C.SNOWBALL_LIFETIME, + }); + this.events.push({ kind: 'throw', x: bx, y: by, id: p.id }); + } + } + + // snowballs (sub-stepped so fast balls can't tunnel) + for (let i = this.snowballs.length - 1; i >= 0; i--) { + const b = this.snowballs[i]; + b.life -= dt; + if (b.life <= 0) { this.snowballs.splice(i, 1); continue; } + + const steps = Math.max(1, Math.ceil((Math.hypot(b.vx, b.vy) * dt) / 6)); + let dead = false; + for (let s = 0; s < steps && !dead; s++) { + b.x += (b.vx * dt) / steps; + b.y += (b.vy * dt) / steps; + + if (b.x < 0 || b.x > C.ARENA_W || b.y < 0 || b.y > C.ARENA_H) { dead = true; break; } + for (const ob of this.obstacles) { + if (circleHitsObstacle(b.x, b.y, C.SNOWBALL_RADIUS, ob)) { dead = true; break; } + } + if (dead) break; + + for (const p of this.players.values()) { + if (!p.alive || !p.inRound || p.id === b.owner) continue; + if (Math.hypot(p.x - b.x, p.y - b.y) < C.PLAYER_RADIUS + C.SNOWBALL_RADIUS) { + p.hp -= 1; + const spd = Math.hypot(b.vx, b.vy) || 1; + p.kbx += (b.vx / spd) * C.KNOCKBACK; + p.kby += (b.vy / spd) * C.KNOCKBACK; + this.events.push({ kind: 'hit', x: b.x, y: b.y, id: p.id }); + if (p.hp <= 0) { + p.alive = false; + this.events.push({ kind: 'die', x: p.x, y: p.y, id: p.id }); + const owner = this.players.get(b.owner); + if (owner) owner.kills++; + } + dead = true; + break; + } + } + } + if (dead) this.snowballs.splice(i, 1); + } + + // round end conditions + const inRound = [...this.players.values()].filter(p => p.inRound); + const alive = inRound.filter(p => p.alive); + + if (inRound.length === 0) { + this.phase = 'lobby'; + return; + } + if (inRound.length === 1) { + this.endRound(inRound[0].alive ? inRound[0].id : null); + return; + } + if (alive.length <= 1) { + this.endRound(alive.length === 1 ? alive[0].id : null); + return; + } + if (this.phaseTime <= 0) { + // Time out: most HP wins, ties get nothing. + let best: ServerPlayer | null = null; + let tie = false; + for (const p of alive) { + if (!best || p.hp > best.hp) { best = p; tie = false; } + else if (p.hp === best.hp) { tie = true; } + } + this.endRound(tie || !best ? null : best.id); + } + } + + // ---------- broadcasting ---------- + + private broadcast(): void { + const snap: Snapshot = { + t: 'state', + tick: this.tickN, + phase: this.phase, + phaseTime: Math.max(0, r2(this.phaseTime)), + round: this.round, + players: [...this.players.values()].map(p => ({ + id: p.id, name: p.name, color: p.color, + x: r2(p.x), y: r2(p.y), angle: r2(p.angle), + hp: p.hp, alive: p.alive, inRound: p.inRound, + kbx: r2(p.kbx), kby: r2(p.kby), + score: p.score, kills: p.kills, cooldown: r2(p.cooldown), + })), + snowballs: this.snowballs.map(b => ({ + id: b.id, owner: b.owner, x: r2(b.x), y: r2(b.y), vx: b.vx, vy: b.vy, + })), + events: this.events, + roundWinnerId: this.roundWinnerId, + matchWinnerId: this.matchWinnerId, + }; + const data = JSON.stringify(snap); + for (const p of this.players.values()) { + if (p.ws.readyState === p.ws.OPEN) p.ws.send(data); + } + this.events = []; + } +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts new file mode 100644 index 0000000..7efb724 --- /dev/null +++ b/src/shared/constants.ts @@ -0,0 +1,39 @@ +// ---- Timing ---- +export const TICK_RATE = 30; // server simulation ticks / second +export const ROUND_TIME = 60; // seconds per round +export const COUNTDOWN_TIME = 3; // seconds before a round starts +export const ROUND_END_TIME = 5; // pause between rounds +export const MATCH_END_TIME = 8; // celebration pause after a match + +// ---- Arena ---- +export const ARENA_W = 1280; +export const ARENA_H = 800; +export const OBSTACLE_COUNT = 12; + +// ---- Players ---- +export const MAX_PLAYERS = 8; +export const PLAYER_RADIUS = 20; +export const PLAYER_SPEED = 230; // px / s +export const MAX_HP = 3; + +// ---- Snowballs ---- +export const SNOWBALL_SPEED = 540; // px / s +export const SNOWBALL_RADIUS = 7; +export const SNOWBALL_LIFETIME = 1.6; // seconds before it fizzles +export const THROW_COOLDOWN = 0.45; // seconds between throws +export const KNOCKBACK = 300; // impulse applied on hit + +// ---- Scoring ---- +export const POINTS_TO_WIN = 5; // round-wins needed to take the match + +// Scarf / name colors, assigned to players in order of least use. +export const PLAYER_COLORS = [ + 0xe74c3c, // red + 0x3498db, // blue + 0x2ecc71, // green + 0xf39c12, // orange + 0x9b59b6, // purple + 0x1abc9c, // teal + 0xe67e22, // dark orange + 0xfd79a8, // pink +]; diff --git a/src/shared/physics.ts b/src/shared/physics.ts new file mode 100644 index 0000000..2ab91b2 --- /dev/null +++ b/src/shared/physics.ts @@ -0,0 +1,123 @@ +// Shared physics + arena generation. Runs identically on server (authoritative) +// and client (prediction), so movement and collisions feel the same on both. + +import { ARENA_W, ARENA_H, OBSTACLE_COUNT, PLAYER_RADIUS } from './constants.js'; + +export interface CircleOb { kind: 'circle'; x: number; y: number; r: number; style: number } +export interface RectOb { kind: 'rect'; x: number; y: number; w: number; h: number; style: number } +export type Obstacle = CircleOb | RectOb; +// style: 0 = pine tree, 1 = rock, 2 = ice block, 3 = snow mound + +export function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return function () { + a |= 0; a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +export function circleHitsObstacle(x: number, y: number, r: number, ob: Obstacle): boolean { + if (ob.kind === 'circle') { + const dx = x - ob.x, dy = y - ob.y; + return dx * dx + dy * dy < (ob.r + r) * (ob.r + r); + } + const cx = Math.max(ob.x, Math.min(x, ob.x + ob.w)); + const cy = Math.max(ob.y, Math.min(y, ob.y + ob.h)); + const dx = x - cx, dy = y - cy; + return dx * dx + dy * dy < r * r; +} + +/** Clamp to arena walls and push a circle out of every obstacle it overlaps. */ +export function resolveCircleMovement( + x: number, y: number, r: number, obstacles: Obstacle[] +): { x: number; y: number } { + if (x < r) x = r; + if (x > ARENA_W - r) x = ARENA_W - r; + if (y < r) y = r; + if (y > ARENA_H - r) y = ARENA_H - r; + + for (const ob of obstacles) { + if (ob.kind === 'circle') { + const dx = x - ob.x, dy = y - ob.y; + const d = Math.hypot(dx, dy); + const min = ob.r + r; + if (d < min) { + if (d === 0) { x = ob.x + min; } + else { const push = (min - d) / d; x += dx * push; y += dy * push; } + } + } else { + const cx = Math.max(ob.x, Math.min(x, ob.x + ob.w)); + const cy = Math.max(ob.y, Math.min(y, ob.y + ob.h)); + const dx = x - cx, dy = y - cy; + const d = Math.hypot(dx, dy); + if (d < r) { + if (d === 0) { + // Center inside rect: push out along the nearest face. + const left = x - ob.x, right = ob.x + ob.w - x; + const top = y - ob.y, bottom = ob.y + ob.h - y; + const m = Math.min(left, right, top, bottom); + if (m === left) x = ob.x - r; + else if (m === right) x = ob.x + ob.w + r; + else if (m === top) y = ob.y - r; + else y = ob.y + ob.h + r; + } else { + const push = (r - d) / d; + x += dx * push; y += dy * push; + } + } + } + } + return { x, y }; +} + +/** Deterministically generate the arena layout from a seed (server picks the seed). */ +export function generateObstacles(seed: number): Obstacle[] { + const rng = mulberry32(seed); + const obs: Obstacle[] = []; + const placed: { x: number; y: number; r: number }[] = []; + let attempts = 0; + + while (obs.length < OBSTACLE_COUNT && attempts < 400) { + attempts++; + const style = obs.length % 4; + if (style === 0 || style === 1) { + const r = style === 0 ? 26 + rng() * 14 : 20 + rng() * 12; + const x = 90 + rng() * (ARENA_W - 180); + const y = 90 + rng() * (ARENA_H - 180); + if (placed.some(p => Math.hypot(p.x - x, p.y - y) < p.r + r + 70)) continue; + placed.push({ x, y, r }); + obs.push({ kind: 'circle', x, y, r, style }); + } else { + const w = style === 2 ? 70 + rng() * 80 : 90 + rng() * 70; + const h = style === 2 ? 46 + rng() * 40 : 40 + rng() * 26; + const x = 80 + rng() * (ARENA_W - 160 - w); + const y = 80 + rng() * (ARENA_H - 160 - h); + const approxR = Math.hypot(w, h) / 2; + const cx = x + w / 2, cy = y + h / 2; + if (placed.some(p => Math.hypot(p.x - cx, p.y - cy) < p.r + approxR + 60)) continue; + placed.push({ x: cx, y: cy, r: approxR }); + obs.push({ kind: 'rect', x, y, w, h, style }); + } + } + return obs; +} + +/** Pick n spawn positions that don't collide with obstacles and aren't on top of each other. */ +export function sampleSpawns( + n: number, obstacles: Obstacle[], rng: () => number +): { x: number; y: number }[] { + const pts: { x: number; y: number }[] = []; + let guard = 0; + while (pts.length < n && guard < 3000) { + guard++; + const x = 60 + rng() * (ARENA_W - 120); + const y = 60 + rng() * (ARENA_H - 120); + if (obstacles.some(o => circleHitsObstacle(x, y, PLAYER_RADIUS + 10, o))) continue; + if (pts.some(p => Math.hypot(p.x - x, p.y - y) < 180)) continue; + pts.push({ x, y }); + } + while (pts.length < n) pts.push({ x: 100 + pts.length * 80, y: 100 }); + return pts; +} diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts new file mode 100644 index 0000000..f869c50 --- /dev/null +++ b/src/shared/protocol.ts @@ -0,0 +1,63 @@ +// Wire protocol shared between server and browser client. + +export type Phase = 'lobby' | 'countdown' | 'round' | 'roundEnd' | 'matchEnd'; + +export interface PlayerState { + id: string; + name: string; + color: number; // index into PLAYER_COLORS + x: number; + y: number; + angle: number; // aim direction, radians + hp: number; + alive: boolean; + inRound: boolean; // false for spectators who joined mid-round + kbx: number; // knockback velocity x + kby: number; // knockback velocity y + score: number; // rounds won this match + kills: number; // total meltdowns inflicted + cooldown: number; // seconds until next throw is ready +} + +export interface SnowballState { + id: number; + owner: string; + x: number; + y: number; + vx: number; + vy: number; +} + +export type GameEvent = + | { kind: 'throw'; x: number; y: number; id: string } + | { kind: 'hit'; x: number; y: number; id: string } + | { kind: 'die'; x: number; y: number; id: string } + | { kind: 'roundStart'; round: number } + | { kind: 'roundEnd'; winnerId: string | null } + | { kind: 'matchEnd'; winnerId: string } + | { kind: 'join'; name: string } + | { kind: 'leave'; name: string }; + +export interface Snapshot { + t: 'state'; + tick: number; + phase: Phase; + phaseTime: number; // seconds left in current phase + round: number; // current round number + players: PlayerState[]; + snowballs: SnowballState[]; + events: GameEvent[]; // things that happened since last snapshot + roundWinnerId: string | null; + matchWinnerId: string | null; +} + +export type ClientMsg = + | { t: 'join'; name: string } + | { t: 'input'; up: boolean; down: boolean; left: boolean; right: boolean; angle: number; fire: boolean } + | { t: 'ping'; ts: number }; + +export type ServerMsg = + | { t: 'welcome'; id: string; seed: number; room: string } + | { t: 'full' } + | { t: 'pong'; ts: number } + | Snapshot; diff --git a/tools/smoke.mjs b/tools/smoke.mjs new file mode 100644 index 0000000..83a2395 --- /dev/null +++ b/tools/smoke.mjs @@ -0,0 +1,70 @@ +// Smoke test: two bot clients join, aim at each other, and fight. +// Verifies welcome/state flow, phases, hits, deaths, round ends, scoring. +// Usage: start the server first, then `node tools/smoke.mjs` +import WebSocket from 'ws'; + +const URL = process.env.SMOKE_URL || 'ws://localhost:3000/ws'; +const seen = { phases: new Set(), events: new Set(), scores: new Set() }; + +function bot(name) { + const ws = new WebSocket(URL); + let me = null; + let latest = null; + + ws.on('open', () => ws.send(JSON.stringify({ t: 'join', name }))); + ws.on('message', (d) => { + const m = JSON.parse(d.toString()); + if (m.t === 'welcome') { + me = m.id; + console.log(`[${name}] welcome, id=${m.id}, seed=${m.seed}`); + return; + } + if (m.t !== 'state') return; + latest = m; + seen.phases.add(m.phase); + for (const e of m.events) { + seen.events.add(e.kind); + if (['die', 'roundEnd', 'matchEnd'].includes(e.kind)) { + console.log(`[${name}] event:`, JSON.stringify(e)); + } + } + const mine = m.players.find((p) => p.id === me); + if (mine) seen.scores.add(`${name}:${mine.score}/${mine.hp}`); + }); + ws.on('error', (e) => { console.error(`[${name}] ws error:`, e.message); }); + + // aim at nearest opponent, shuffle around, hold fire + setInterval(() => { + if (ws.readyState !== 1 || !latest) return; + const mine = latest.players.find((p) => p.id === me); + if (!mine) return; + const others = latest.players.filter((p) => p.id !== me && p.alive && p.inRound); + let angle = Math.random() * Math.PI * 2; + if (others.length > 0) { + const t = others[0]; + angle = Math.atan2(t.y - mine.y, t.x - mine.x); + } + ws.send(JSON.stringify({ + t: 'input', + up: Math.random() < 0.3, + down: Math.random() < 0.3, + left: Math.random() < 0.3, + right: Math.random() < 0.3, + angle, + fire: true, + })); + }, 60); +} + +bot('Alpha'); +setTimeout(() => bot('Bravo'), 400); + +setTimeout(() => { + console.log('\n--- smoke summary ---'); + console.log('phases seen :', [...seen.phases].join(', ')); + console.log('events seen :', [...seen.events].join(', ')); + console.log('score/hp samples:', [...seen.scores].slice(0, 12).join(' ')); + const ok = seen.phases.has('round') && seen.events.has('throw') && seen.events.has('hit'); + console.log(ok ? '✅ SMOKE OK' : '⚠️ smoke inconclusive (check output above)'); + process.exit(0); +}, Number(process.env.SMOKE_MS || 25000)); diff --git a/tsconfig.client.json b/tsconfig.client.json new file mode 100644 index 0000000..de0cf6d --- /dev/null +++ b/tsconfig.client.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "public/js", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src/shared", "src/client"] +} diff --git a/tsconfig.server.json b/tsconfig.server.json new file mode 100644 index 0000000..7736186 --- /dev/null +++ b/tsconfig.server.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src/shared", "src/server"] +}