Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
17 kB · 595 lines
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596// Duel Manager, 2026.03.30// Server-authoritative game logic for dumduel.// Quake 3-style: server owns state, clients send inputs, server broadcasts snapshots.
const ARENA_W = 220;const ARENA_H = 220;const BULLET_SPEED = 0.7;const MOVE_SPEED = 1.0;const HIT_R = 7;const COUNTDOWN_TICKS = 180; // 3s at 60Hzconst ROUND_OVER_TICKS = 120; // 2sconst TICK_RATE = 60; // server sim Hzconst SNAPSHOT_INTERVAL = 3; // send snapshot every N ticks (~20Hz)const BULLET_MAX_AGE = 200;const DUMMY_HANDLE = "dummy";
function norm(dx, dy) { const len = Math.sqrt(dx * dx + dy * dy); if (len < 0.001) return { nx: 0, ny: 0 }; return { nx: dx / len, ny: dy / len };}
export class DuelManager { constructor() { this.players = new Map(); // handle -> PlayerRecord this.spectators = new Map(); // handle -> { wsId } this.roster = []; // handles in queue order this.phase = "waiting"; this.tick = 0; this.countdownTimer = 0; this.roundOverTimer = 0; this.roundWinner = null; this.bullets = []; this.tickInterval = null;
// Send function callbacks (set by session.mjs) this.sendUDP = null; // (channelId, event, data) this.sendWS = null; // (wsId, type, content) this.broadcastWS = null; // (type, content) this.resolveUdpForHandle = null; // (handle) -> channelId|null }
// Called by session.mjs to wire up transport setSendFunctions({ sendUDP, sendWS, broadcastWS, resolveUdpForHandle }) { this.sendUDP = sendUDP; this.sendWS = sendWS; this.broadcastWS = broadcastWS; this.resolveUdpForHandle = resolveUdpForHandle; }
// -- Player Management --
playerJoin(handle, wsId) { if (!handle) return; // Guests spectate — track their wsId so they receive snapshots if (handle.startsWith("guest_")) { this.spectators.set(handle, { wsId }); // Send current state so they see the game immediately this.sendWS?.(wsId, "duel:joined", { roster: this.roster, phase: this.phase, spectator: true, }); console.log(`🎯 Duel: ${handle} joined as spectator (${this.spectators.size} spectators)`); return; }
// Update existing or create new let player = this.players.get(handle); if (player) { player.wsId = wsId; } else { player = { handle, wsId, udpChannelId: null, x: 0, y: 0, targetX: 0, targetY: 0, alive: true, wasMoving: false, lastInputSeq: 0, ping: 0, pingTs: 0, }; this.players.set(handle, player); }
// Add to roster if not already present if (!this.roster.includes(handle)) { this.roster.push(handle); }
// Try to resolve UDP channel this.tryResolveUdp(handle);
// Send current state to joiner this.sendWS?.(wsId, "duel:joined", { roster: this.roster, phase: this.phase, });
// Broadcast updated roster to all this.broadcastWS?.("duel:roster", { roster: this.roster, phase: this.phase });
console.log(`🎯 Duel: ${handle} joined. Roster: [${this.roster.join(", ")}]`);
// Start game if we have enough players this.checkStart(); }
playerLeave(handle) { if (!handle) return; if (this.spectators.has(handle)) { this.spectators.delete(handle); console.log(`🎯 Duel: spectator ${handle} left (${this.spectators.size} spectators)`); return; } const wasInRoster = this.roster.includes(handle); const wasDueling = this.isDuelist(handle);
this.roster = this.roster.filter((h) => h !== handle); this.players.delete(handle);
// Remove dummy if it was paired with the leaving player if (this.roster.includes(DUMMY_HANDLE) && this.roster.length <= 1) { this.roster = this.roster.filter((h) => h !== DUMMY_HANDLE); this.players.delete(DUMMY_HANDLE); }
if (wasDueling && (this.phase === "fight" || this.phase === "countdown")) { // Opponent wins by default const remaining = this.getDuelists().find((h) => h !== handle); if (remaining && remaining !== DUMMY_HANDLE) { this.endRound(remaining); } else { this.resetToWaiting(); } }
if (wasInRoster) { this.broadcastWS?.("duel:roster", { roster: this.roster, phase: this.phase }); console.log(`🎯 Duel: ${handle} left. Roster: [${this.roster.join(", ")}]`); }
this.checkStart();
// Stop tick if nobody left if (this.roster.filter((h) => h !== DUMMY_HANDLE).length === 0) { this.stopTick(); } }
resolveUdpChannel(handle, channelId) { const player = this.players.get(handle); if (player) { player.udpChannelId = channelId; } }
tryResolveUdp(handle) { if (!this.resolveUdpForHandle) return; const channelId = this.resolveUdpForHandle(handle); if (channelId) { const player = this.players.get(handle); if (player) player.udpChannelId = channelId; } }
// -- Input Processing --
receiveInput(handle, input) { const player = this.players.get(handle); if (!player || !player.alive) return; if (!this.isDuelist(handle)) return; if (this.phase !== "fight" && this.phase !== "countdown") return;
player.targetX = Math.max(6, Math.min(ARENA_W - 6, input.targetX)); player.targetY = Math.max(6, Math.min(ARENA_H - 6, input.targetY)); if (input.seq > player.lastInputSeq) { player.lastInputSeq = input.seq; } // Log first few inputs if (input.seq <= 3) { console.log(`🎯 Input from ${handle}: seq=${input.seq} target=(${input.targetX.toFixed(1)}, ${input.targetY.toFixed(1)})`); } }
handlePing(handle, ts, wsId) { const player = this.players.get(handle); if (player) { player.ping = Date.now() - ts; } this.sendWS?.(wsId, "duel:pong", { ts, serverTime: Date.now() }); }
// -- Game Logic --
getDuelists() { if (this.roster.length < 2) return []; return [this.roster[0], this.roster[1]]; }
isDuelist(handle) { const d = this.getDuelists(); return d.includes(handle); }
checkStart() { const realPlayers = this.roster.filter((h) => h !== DUMMY_HANDLE);
if (realPlayers.length === 0) { this.resetToWaiting(); this.stopTick(); return; }
if (realPlayers.length === 1) { // Solo — start practice with dummy (regardless of current phase) // Remove dummy first if stale if (this.roster.includes(DUMMY_HANDLE)) { this.roster = this.roster.filter((h) => h !== DUMMY_HANDLE); this.players.delete(DUMMY_HANDLE); } this.bullets = []; this.phase = "waiting"; // reset phase so startPractice works this.startPractice(realPlayers[0]); return; }
// Remove dummy if real opponent available if (realPlayers.length >= 2 && this.roster.includes(DUMMY_HANDLE)) { this.roster = this.roster.filter((h) => h !== DUMMY_HANDLE); this.players.delete(DUMMY_HANDLE); this.bullets = []; }
if (this.roster.length >= 2 && (this.phase === "waiting" || this.phase === "roundover")) { this.startCountdown(); } }
startPractice(handle) { // Add dummy if (!this.roster.includes(DUMMY_HANDLE)) { this.roster.push(DUMMY_HANDLE); this.players.set(DUMMY_HANDLE, { handle: DUMMY_HANDLE, wsId: null, udpChannelId: null, x: ARENA_W - 30, y: ARENA_H - 30, targetX: ARENA_W - 30, targetY: ARENA_H - 30, alive: true, wasMoving: false, lastInputSeq: 0, ping: 0, pingTs: 0, }); }
// Ensure handle is first in roster this.roster = this.roster.filter((h) => h !== handle && h !== DUMMY_HANDLE); this.roster.unshift(handle); this.roster.push(DUMMY_HANDLE);
this.startCountdown(); }
startCountdown() { this.phase = "countdown"; this.countdownTimer = COUNTDOWN_TICKS; this.bullets = []; this.roundWinner = null;
const duelists = this.getDuelists(); // Deterministic slots: alphabetical order const sorted = [...duelists].sort(); const spawnA = { x: 30, y: 30 }; const spawnB = { x: ARENA_W - 30, y: ARENA_H - 30 };
for (const h of duelists) { const p = this.players.get(h); if (!p) continue; const spawn = h === sorted[0] ? spawnA : spawnB; p.x = spawn.x; p.y = spawn.y; p.targetX = spawn.x; p.targetY = spawn.y; p.alive = true; p.wasMoving = false; }
this.broadcastWS?.("duel:countdown", { duelists, timer: this.countdownTimer, });
console.log(`🎯 Duel countdown: ${duelists.join(" vs ")} (phase: ${this.phase})`); this.ensureTick(); }
startFight() { this.phase = "fight"; this.broadcastWS?.("duel:fight", {}); console.log(`🎯 Duel fight started! Tick loop active.`); }
endRound(winnerHandle) { this.roundWinner = winnerHandle; this.phase = "roundover"; this.roundOverTimer = ROUND_OVER_TICKS;
const duelists = this.getDuelists(); const loser = duelists.find((h) => h !== winnerHandle) || "???";
// Mark loser dead const loserPlayer = this.players.get(loser); if (loserPlayer) loserPlayer.alive = false;
this.broadcastWS?.("duel:death", { victim: loser, killer: winnerHandle }); this.broadcastWS?.("duel:roundover", { winner: winnerHandle, loser });
console.log(`🎯 Duel round: ${winnerHandle} killed ${loser}`); }
advanceStack() { if (this.roster.length >= 2 && this.roundWinner) { // Loser goes to bottom const duelists = this.getDuelists(); const loserHandle = duelists.find((h) => h !== this.roundWinner); if (loserHandle) { this.roster = this.roster.filter((h) => h !== loserHandle); this.roster.push(loserHandle); } }
this.roundWinner = null; this.bullets = [];
this.broadcastWS?.("duel:advance", { roster: this.roster });
// Check what to do next const realPlayers = this.roster.filter((h) => h !== DUMMY_HANDLE); if (realPlayers.length >= 2) { this.startCountdown(); } else if (realPlayers.length === 1) { // Remove dummy, restart practice this.roster = this.roster.filter((h) => h !== DUMMY_HANDLE); this.players.delete(DUMMY_HANDLE); this.phase = "waiting"; this.startPractice(realPlayers[0]); } else { this.resetToWaiting(); } }
resetToWaiting() { this.phase = "waiting"; this.bullets = []; this.roundWinner = null; this.countdownTimer = 0; this.roundOverTimer = 0; }
// -- Server Tick --
// Purge stale guest handles from roster purgeGuests() { const guests = this.roster.filter((h) => h.startsWith("guest_")); for (const g of guests) { this.roster = this.roster.filter((h) => h !== g); this.players.delete(g); console.log(`🎯 Purged stale guest: ${g}`); } }
ensureTick() { if (!this.tickInterval) { this.purgeGuests(); // Clean up any stale guests before starting this.tickInterval = setInterval(() => this.serverTick(), 1000 / TICK_RATE); console.log(`🎯 Duel tick loop started (${TICK_RATE}Hz, snapshot every ${SNAPSHOT_INTERVAL} ticks)`); } }
stopTick() { if (this.tickInterval) { clearInterval(this.tickInterval); this.tickInterval = null; console.log(`🎯 Duel tick loop stopped`); } this.resetToWaiting(); }
serverTick() { this.tick++;
if (this.phase === "countdown") { this.countdownTimer--; this.tickDummy(); this.tickMovement(); if (this.countdownTimer <= 0) this.startFight(); }
if (this.phase === "fight") { this.tickDummy(); this.tickMovement(); this.tickFireOnStop(); this.tickBullets(); this.tickHitDetection(); }
if (this.phase === "roundover") { this.roundOverTimer--; if (this.roundOverTimer <= 0) this.advanceStack(); }
// Broadcast snapshot at reduced rate if (this.tick % SNAPSHOT_INTERVAL === 0) { this.broadcastSnapshot(); } }
tickDummy() { const dummy = this.players.get(DUMMY_HANDLE); if (!dummy || !dummy.alive) return;
// Wander every ~90 ticks if (this.tick % 90 === 0) { dummy.targetX = 20 + Math.random() * (ARENA_W - 40); dummy.targetY = 20 + Math.random() * (ARENA_H - 40); } }
tickMovement() { const duelists = this.getDuelists(); for (const h of duelists) { const p = this.players.get(h); if (!p || !p.alive) continue;
const dx = p.targetX - p.x; const dy = p.targetY - p.y; const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > 2) { const speed = h === DUMMY_HANDLE ? MOVE_SPEED * 0.7 : MOVE_SPEED; p.x += (dx / dist) * speed; p.y += (dy / dist) * speed; } } }
tickFireOnStop() { const duelists = this.getDuelists(); for (const h of duelists) { if (h === DUMMY_HANDLE) continue; // dummy doesn't fire const p = this.players.get(h); if (!p || !p.alive) continue;
const dx = p.targetX - p.x; const dy = p.targetY - p.y; const isMoving = dx * dx + dy * dy > 4;
// Fire when transitioning from moving to stopped if (p.wasMoving && !isMoving) { const opHandle = duelists.find((d) => d !== h); const op = opHandle ? this.players.get(opHandle) : null; if (op && op.alive) { const { nx, ny } = norm(op.x - p.x, op.y - p.y); this.bullets.push({ x: p.x + nx * 6, y: p.y + ny * 6, vx: nx * BULLET_SPEED, vy: ny * BULLET_SPEED, ownerHandle: h, age: 0, }); console.log(`🎯 ${h} fired! bullets=${this.bullets.length}`); this.broadcastSnapshot(); } }
// Update wasMoving AFTER the fire check p.wasMoving = isMoving; } }
tickBullets() { for (let i = this.bullets.length - 1; i >= 0; i--) { const b = this.bullets[i]; b.x += b.vx; b.y += b.vy; b.age++;
// Remove if off-arena or too old if ( b.age > BULLET_MAX_AGE || b.x < -10 || b.x > ARENA_W + 10 || b.y < -10 || b.y > ARENA_H + 10 ) { this.bullets.splice(i, 1); } } }
tickHitDetection() { const duelists = this.getDuelists(); for (let i = this.bullets.length - 1; i >= 0; i--) { const b = this.bullets[i]; // Check against non-owner duelist for (const h of duelists) { if (h === b.ownerHandle) continue; const p = this.players.get(h); if (!p || !p.alive) continue;
const dx = b.x - p.x; const dy = b.y - p.y; if (dx * dx + dy * dy < HIT_R * HIT_R) { // Hit! Server-authoritative kill this.bullets.splice(i, 1); this.endRound(b.ownerHandle); return; // only one kill per tick } } } }
// -- Snapshot Broadcasting --
broadcastSnapshot() { const duelists = this.getDuelists(); const playersData = duelists.map((h) => { const p = this.players.get(h); if (!p) return null; return { handle: h, x: Math.round(p.x * 10) / 10, y: Math.round(p.y * 10) / 10, targetX: Math.round(p.targetX * 10) / 10, targetY: Math.round(p.targetY * 10) / 10, alive: p.alive, ping: p.ping, }; }).filter(Boolean);
const bulletsData = this.bullets.map((b) => ({ x: Math.round(b.x * 10) / 10, y: Math.round(b.y * 10) / 10, vx: b.vx, vy: b.vy, owner: b.ownerHandle, age: b.age, }));
const lastInputSeq = {}; for (const h of duelists) { const p = this.players.get(h); if (p) lastInputSeq[h] = p.lastInputSeq; }
const snapshot = { tick: this.tick, phase: this.phase, countdownTimer: this.countdownTimer, roundOverTimer: this.roundOverTimer, roundWinner: this.roundWinner, players: playersData, bullets: bulletsData, roster: this.roster, lastInputSeq, };
const data = JSON.stringify(snapshot);
// Log periodic snapshot info + always log when bullets present if (this.tick % 300 === 0 || bulletsData.length > 0) { const channels = []; for (const [h, p] of this.players) { if (h === DUMMY_HANDLE) continue; channels.push(`${h}:${p.udpChannelId ? "UDP" : p.wsId != null ? "WS" : "NONE"}`); } console.log(`🎯 Duel snapshot #${this.tick} phase=${this.phase} bullets=${bulletsData.length} via [${channels.join(", ")}]`); }
// Always send via WS for reliability (UDP was silently dropping packets) for (const [handle, player] of this.players) { if (handle === DUMMY_HANDLE) continue; if (player.wsId != null && this.sendWS) { this.sendWS(player.wsId, "duel:snapshot", snapshot); } } // Send to spectators too for (const [, spec] of this.spectators) { if (spec.wsId != null && this.sendWS) { this.sendWS(spec.wsId, "duel:snapshot", snapshot); } } }}