inital commit

This commit is contained in:
2026-07-18 12:17:09 -05:00
commit 9a56afd09f
17 changed files with 2055 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
public/js/
server.log
+82
View File
@@ -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
}
}
}
}
}
+20
View File
@@ -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"
}
}
+30
View File
@@ -0,0 +1,30 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="data:,">
<title>Yellow Snow Wars</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<canvas id="game"></canvas>
<div id="join-overlay">
<div class="panel">
<h1>❄ YELLOW SNOW WARS ❄</h1>
<p class="tag">Snowmen. Yellow snowballs. No questions asked.</p>
<input id="name" maxlength="14" placeholder="Snowman name" autocomplete="off">
<button id="play">JOIN BATTLE</button>
<p id="status"></p>
<div class="help">
<b>WASD / arrows</b> move &nbsp;•&nbsp; <b>Mouse</b> aim &nbsp;•&nbsp; <b>Click / Space</b> throw<br>
Win rounds to earn ★ — first to 5 ★ takes the match.<br>
Got 3 ❤. Yellow snow melts snowmen. Science.
</div>
</div>
</div>
<script type="module" src="/js/client/index.js"></script>
</body>
</html>
+93
View File
@@ -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; }
+361
View File
@@ -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<string, keyof typeof keys> = {
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<string, number>();
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();
+94
View File
@@ -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();
}
}
}
+533
View File
@@ -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);
}
+67
View File
@@ -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); },
};
+79
View File
@@ -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<string, string> = {
'.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<string, Room>();
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)`);
});
+369
View File
@@ -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<string, ServerPlayer>();
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<number>(C.PLAYER_COLORS.length).fill(0);
private timer: ReturnType<typeof setInterval>;
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 = [];
}
}
+39
View File
@@ -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
];
+123
View File
@@ -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;
}
+63
View File
@@ -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;
+70
View File
@@ -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));
+14
View File
@@ -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"]
}
+14
View File
@@ -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"]
}