Monorepo for Aesthetic.Computer aesthetic.computer
at main 595 lines 17 kB view raw
1// Duel Manager, 2026.03.30 2// Server-authoritative game logic for dumduel. 3// Quake 3-style: server owns state, clients send inputs, server broadcasts snapshots. 4 5const ARENA_W = 220; 6const ARENA_H = 220; 7const BULLET_SPEED = 0.7; 8const MOVE_SPEED = 1.0; 9const HIT_R = 7; 10const COUNTDOWN_TICKS = 180; // 3s at 60Hz 11const ROUND_OVER_TICKS = 120; // 2s 12const TICK_RATE = 60; // server sim Hz 13const SNAPSHOT_INTERVAL = 3; // send snapshot every N ticks (~20Hz) 14const BULLET_MAX_AGE = 200; 15const DUMMY_HANDLE = "dummy"; 16 17function norm(dx, dy) { 18 const len = Math.sqrt(dx * dx + dy * dy); 19 if (len < 0.001) return { nx: 0, ny: 0 }; 20 return { nx: dx / len, ny: dy / len }; 21} 22 23export class DuelManager { 24 constructor() { 25 this.players = new Map(); // handle -> PlayerRecord 26 this.spectators = new Map(); // handle -> { wsId } 27 this.roster = []; // handles in queue order 28 this.phase = "waiting"; 29 this.tick = 0; 30 this.countdownTimer = 0; 31 this.roundOverTimer = 0; 32 this.roundWinner = null; 33 this.bullets = []; 34 this.tickInterval = null; 35 36 // Send function callbacks (set by session.mjs) 37 this.sendUDP = null; // (channelId, event, data) 38 this.sendWS = null; // (wsId, type, content) 39 this.broadcastWS = null; // (type, content) 40 this.resolveUdpForHandle = null; // (handle) -> channelId|null 41 } 42 43 // Called by session.mjs to wire up transport 44 setSendFunctions({ sendUDP, sendWS, broadcastWS, resolveUdpForHandle }) { 45 this.sendUDP = sendUDP; 46 this.sendWS = sendWS; 47 this.broadcastWS = broadcastWS; 48 this.resolveUdpForHandle = resolveUdpForHandle; 49 } 50 51 // -- Player Management -- 52 53 playerJoin(handle, wsId) { 54 if (!handle) return; 55 // Guests spectate — track their wsId so they receive snapshots 56 if (handle.startsWith("guest_")) { 57 this.spectators.set(handle, { wsId }); 58 // Send current state so they see the game immediately 59 this.sendWS?.(wsId, "duel:joined", { 60 roster: this.roster, 61 phase: this.phase, 62 spectator: true, 63 }); 64 console.log(`🎯 Duel: ${handle} joined as spectator (${this.spectators.size} spectators)`); 65 return; 66 } 67 68 // Update existing or create new 69 let player = this.players.get(handle); 70 if (player) { 71 player.wsId = wsId; 72 } else { 73 player = { 74 handle, 75 wsId, 76 udpChannelId: null, 77 x: 0, y: 0, 78 targetX: 0, targetY: 0, 79 alive: true, 80 wasMoving: false, 81 lastInputSeq: 0, 82 ping: 0, 83 pingTs: 0, 84 }; 85 this.players.set(handle, player); 86 } 87 88 // Add to roster if not already present 89 if (!this.roster.includes(handle)) { 90 this.roster.push(handle); 91 } 92 93 // Try to resolve UDP channel 94 this.tryResolveUdp(handle); 95 96 // Send current state to joiner 97 this.sendWS?.(wsId, "duel:joined", { 98 roster: this.roster, 99 phase: this.phase, 100 }); 101 102 // Broadcast updated roster to all 103 this.broadcastWS?.("duel:roster", { roster: this.roster, phase: this.phase }); 104 105 console.log(`🎯 Duel: ${handle} joined. Roster: [${this.roster.join(", ")}]`); 106 107 // Start game if we have enough players 108 this.checkStart(); 109 } 110 111 playerLeave(handle) { 112 if (!handle) return; 113 if (this.spectators.has(handle)) { 114 this.spectators.delete(handle); 115 console.log(`🎯 Duel: spectator ${handle} left (${this.spectators.size} spectators)`); 116 return; 117 } 118 const wasInRoster = this.roster.includes(handle); 119 const wasDueling = this.isDuelist(handle); 120 121 this.roster = this.roster.filter((h) => h !== handle); 122 this.players.delete(handle); 123 124 // Remove dummy if it was paired with the leaving player 125 if (this.roster.includes(DUMMY_HANDLE) && this.roster.length <= 1) { 126 this.roster = this.roster.filter((h) => h !== DUMMY_HANDLE); 127 this.players.delete(DUMMY_HANDLE); 128 } 129 130 if (wasDueling && (this.phase === "fight" || this.phase === "countdown")) { 131 // Opponent wins by default 132 const remaining = this.getDuelists().find((h) => h !== handle); 133 if (remaining && remaining !== DUMMY_HANDLE) { 134 this.endRound(remaining); 135 } else { 136 this.resetToWaiting(); 137 } 138 } 139 140 if (wasInRoster) { 141 this.broadcastWS?.("duel:roster", { roster: this.roster, phase: this.phase }); 142 console.log(`🎯 Duel: ${handle} left. Roster: [${this.roster.join(", ")}]`); 143 } 144 145 this.checkStart(); 146 147 // Stop tick if nobody left 148 if (this.roster.filter((h) => h !== DUMMY_HANDLE).length === 0) { 149 this.stopTick(); 150 } 151 } 152 153 resolveUdpChannel(handle, channelId) { 154 const player = this.players.get(handle); 155 if (player) { 156 player.udpChannelId = channelId; 157 } 158 } 159 160 tryResolveUdp(handle) { 161 if (!this.resolveUdpForHandle) return; 162 const channelId = this.resolveUdpForHandle(handle); 163 if (channelId) { 164 const player = this.players.get(handle); 165 if (player) player.udpChannelId = channelId; 166 } 167 } 168 169 // -- Input Processing -- 170 171 receiveInput(handle, input) { 172 const player = this.players.get(handle); 173 if (!player || !player.alive) return; 174 if (!this.isDuelist(handle)) return; 175 if (this.phase !== "fight" && this.phase !== "countdown") return; 176 177 player.targetX = Math.max(6, Math.min(ARENA_W - 6, input.targetX)); 178 player.targetY = Math.max(6, Math.min(ARENA_H - 6, input.targetY)); 179 if (input.seq > player.lastInputSeq) { 180 player.lastInputSeq = input.seq; 181 } 182 // Log first few inputs 183 if (input.seq <= 3) { 184 console.log(`🎯 Input from ${handle}: seq=${input.seq} target=(${input.targetX.toFixed(1)}, ${input.targetY.toFixed(1)})`); 185 } 186 } 187 188 handlePing(handle, ts, wsId) { 189 const player = this.players.get(handle); 190 if (player) { 191 player.ping = Date.now() - ts; 192 } 193 this.sendWS?.(wsId, "duel:pong", { ts, serverTime: Date.now() }); 194 } 195 196 // -- Game Logic -- 197 198 getDuelists() { 199 if (this.roster.length < 2) return []; 200 return [this.roster[0], this.roster[1]]; 201 } 202 203 isDuelist(handle) { 204 const d = this.getDuelists(); 205 return d.includes(handle); 206 } 207 208 checkStart() { 209 const realPlayers = this.roster.filter((h) => h !== DUMMY_HANDLE); 210 211 if (realPlayers.length === 0) { 212 this.resetToWaiting(); 213 this.stopTick(); 214 return; 215 } 216 217 if (realPlayers.length === 1) { 218 // Solo — start practice with dummy (regardless of current phase) 219 // Remove dummy first if stale 220 if (this.roster.includes(DUMMY_HANDLE)) { 221 this.roster = this.roster.filter((h) => h !== DUMMY_HANDLE); 222 this.players.delete(DUMMY_HANDLE); 223 } 224 this.bullets = []; 225 this.phase = "waiting"; // reset phase so startPractice works 226 this.startPractice(realPlayers[0]); 227 return; 228 } 229 230 // Remove dummy if real opponent available 231 if (realPlayers.length >= 2 && this.roster.includes(DUMMY_HANDLE)) { 232 this.roster = this.roster.filter((h) => h !== DUMMY_HANDLE); 233 this.players.delete(DUMMY_HANDLE); 234 this.bullets = []; 235 } 236 237 if (this.roster.length >= 2 && (this.phase === "waiting" || this.phase === "roundover")) { 238 this.startCountdown(); 239 } 240 } 241 242 startPractice(handle) { 243 // Add dummy 244 if (!this.roster.includes(DUMMY_HANDLE)) { 245 this.roster.push(DUMMY_HANDLE); 246 this.players.set(DUMMY_HANDLE, { 247 handle: DUMMY_HANDLE, 248 wsId: null, 249 udpChannelId: null, 250 x: ARENA_W - 30, y: ARENA_H - 30, 251 targetX: ARENA_W - 30, targetY: ARENA_H - 30, 252 alive: true, 253 wasMoving: false, 254 lastInputSeq: 0, 255 ping: 0, 256 pingTs: 0, 257 }); 258 } 259 260 // Ensure handle is first in roster 261 this.roster = this.roster.filter((h) => h !== handle && h !== DUMMY_HANDLE); 262 this.roster.unshift(handle); 263 this.roster.push(DUMMY_HANDLE); 264 265 this.startCountdown(); 266 } 267 268 startCountdown() { 269 this.phase = "countdown"; 270 this.countdownTimer = COUNTDOWN_TICKS; 271 this.bullets = []; 272 this.roundWinner = null; 273 274 const duelists = this.getDuelists(); 275 // Deterministic slots: alphabetical order 276 const sorted = [...duelists].sort(); 277 const spawnA = { x: 30, y: 30 }; 278 const spawnB = { x: ARENA_W - 30, y: ARENA_H - 30 }; 279 280 for (const h of duelists) { 281 const p = this.players.get(h); 282 if (!p) continue; 283 const spawn = h === sorted[0] ? spawnA : spawnB; 284 p.x = spawn.x; p.y = spawn.y; 285 p.targetX = spawn.x; p.targetY = spawn.y; 286 p.alive = true; 287 p.wasMoving = false; 288 } 289 290 this.broadcastWS?.("duel:countdown", { 291 duelists, 292 timer: this.countdownTimer, 293 }); 294 295 console.log(`🎯 Duel countdown: ${duelists.join(" vs ")} (phase: ${this.phase})`); 296 this.ensureTick(); 297 } 298 299 startFight() { 300 this.phase = "fight"; 301 this.broadcastWS?.("duel:fight", {}); 302 console.log(`🎯 Duel fight started! Tick loop active.`); 303 } 304 305 endRound(winnerHandle) { 306 this.roundWinner = winnerHandle; 307 this.phase = "roundover"; 308 this.roundOverTimer = ROUND_OVER_TICKS; 309 310 const duelists = this.getDuelists(); 311 const loser = duelists.find((h) => h !== winnerHandle) || "???"; 312 313 // Mark loser dead 314 const loserPlayer = this.players.get(loser); 315 if (loserPlayer) loserPlayer.alive = false; 316 317 this.broadcastWS?.("duel:death", { victim: loser, killer: winnerHandle }); 318 this.broadcastWS?.("duel:roundover", { winner: winnerHandle, loser }); 319 320 console.log(`🎯 Duel round: ${winnerHandle} killed ${loser}`); 321 } 322 323 advanceStack() { 324 if (this.roster.length >= 2 && this.roundWinner) { 325 // Loser goes to bottom 326 const duelists = this.getDuelists(); 327 const loserHandle = duelists.find((h) => h !== this.roundWinner); 328 if (loserHandle) { 329 this.roster = this.roster.filter((h) => h !== loserHandle); 330 this.roster.push(loserHandle); 331 } 332 } 333 334 this.roundWinner = null; 335 this.bullets = []; 336 337 this.broadcastWS?.("duel:advance", { roster: this.roster }); 338 339 // Check what to do next 340 const realPlayers = this.roster.filter((h) => h !== DUMMY_HANDLE); 341 if (realPlayers.length >= 2) { 342 this.startCountdown(); 343 } else if (realPlayers.length === 1) { 344 // Remove dummy, restart practice 345 this.roster = this.roster.filter((h) => h !== DUMMY_HANDLE); 346 this.players.delete(DUMMY_HANDLE); 347 this.phase = "waiting"; 348 this.startPractice(realPlayers[0]); 349 } else { 350 this.resetToWaiting(); 351 } 352 } 353 354 resetToWaiting() { 355 this.phase = "waiting"; 356 this.bullets = []; 357 this.roundWinner = null; 358 this.countdownTimer = 0; 359 this.roundOverTimer = 0; 360 } 361 362 // -- Server Tick -- 363 364 // Purge stale guest handles from roster 365 purgeGuests() { 366 const guests = this.roster.filter((h) => h.startsWith("guest_")); 367 for (const g of guests) { 368 this.roster = this.roster.filter((h) => h !== g); 369 this.players.delete(g); 370 console.log(`🎯 Purged stale guest: ${g}`); 371 } 372 } 373 374 ensureTick() { 375 if (!this.tickInterval) { 376 this.purgeGuests(); // Clean up any stale guests before starting 377 this.tickInterval = setInterval(() => this.serverTick(), 1000 / TICK_RATE); 378 console.log(`🎯 Duel tick loop started (${TICK_RATE}Hz, snapshot every ${SNAPSHOT_INTERVAL} ticks)`); 379 } 380 } 381 382 stopTick() { 383 if (this.tickInterval) { 384 clearInterval(this.tickInterval); 385 this.tickInterval = null; 386 console.log(`🎯 Duel tick loop stopped`); 387 } 388 this.resetToWaiting(); 389 } 390 391 serverTick() { 392 this.tick++; 393 394 if (this.phase === "countdown") { 395 this.countdownTimer--; 396 this.tickDummy(); 397 this.tickMovement(); 398 if (this.countdownTimer <= 0) this.startFight(); 399 } 400 401 if (this.phase === "fight") { 402 this.tickDummy(); 403 this.tickMovement(); 404 this.tickFireOnStop(); 405 this.tickBullets(); 406 this.tickHitDetection(); 407 } 408 409 if (this.phase === "roundover") { 410 this.roundOverTimer--; 411 if (this.roundOverTimer <= 0) this.advanceStack(); 412 } 413 414 // Broadcast snapshot at reduced rate 415 if (this.tick % SNAPSHOT_INTERVAL === 0) { 416 this.broadcastSnapshot(); 417 } 418 } 419 420 tickDummy() { 421 const dummy = this.players.get(DUMMY_HANDLE); 422 if (!dummy || !dummy.alive) return; 423 424 // Wander every ~90 ticks 425 if (this.tick % 90 === 0) { 426 dummy.targetX = 20 + Math.random() * (ARENA_W - 40); 427 dummy.targetY = 20 + Math.random() * (ARENA_H - 40); 428 } 429 } 430 431 tickMovement() { 432 const duelists = this.getDuelists(); 433 for (const h of duelists) { 434 const p = this.players.get(h); 435 if (!p || !p.alive) continue; 436 437 const dx = p.targetX - p.x; 438 const dy = p.targetY - p.y; 439 const dist = Math.sqrt(dx * dx + dy * dy); 440 441 if (dist > 2) { 442 const speed = h === DUMMY_HANDLE ? MOVE_SPEED * 0.7 : MOVE_SPEED; 443 p.x += (dx / dist) * speed; 444 p.y += (dy / dist) * speed; 445 } 446 } 447 } 448 449 tickFireOnStop() { 450 const duelists = this.getDuelists(); 451 for (const h of duelists) { 452 if (h === DUMMY_HANDLE) continue; // dummy doesn't fire 453 const p = this.players.get(h); 454 if (!p || !p.alive) continue; 455 456 const dx = p.targetX - p.x; 457 const dy = p.targetY - p.y; 458 const isMoving = dx * dx + dy * dy > 4; 459 460 // Fire when transitioning from moving to stopped 461 if (p.wasMoving && !isMoving) { 462 const opHandle = duelists.find((d) => d !== h); 463 const op = opHandle ? this.players.get(opHandle) : null; 464 if (op && op.alive) { 465 const { nx, ny } = norm(op.x - p.x, op.y - p.y); 466 this.bullets.push({ 467 x: p.x + nx * 6, 468 y: p.y + ny * 6, 469 vx: nx * BULLET_SPEED, 470 vy: ny * BULLET_SPEED, 471 ownerHandle: h, 472 age: 0, 473 }); 474 console.log(`🎯 ${h} fired! bullets=${this.bullets.length}`); 475 this.broadcastSnapshot(); 476 } 477 } 478 479 // Update wasMoving AFTER the fire check 480 p.wasMoving = isMoving; 481 } 482 } 483 484 tickBullets() { 485 for (let i = this.bullets.length - 1; i >= 0; i--) { 486 const b = this.bullets[i]; 487 b.x += b.vx; 488 b.y += b.vy; 489 b.age++; 490 491 // Remove if off-arena or too old 492 if ( 493 b.age > BULLET_MAX_AGE || 494 b.x < -10 || b.x > ARENA_W + 10 || 495 b.y < -10 || b.y > ARENA_H + 10 496 ) { 497 this.bullets.splice(i, 1); 498 } 499 } 500 } 501 502 tickHitDetection() { 503 const duelists = this.getDuelists(); 504 for (let i = this.bullets.length - 1; i >= 0; i--) { 505 const b = this.bullets[i]; 506 // Check against non-owner duelist 507 for (const h of duelists) { 508 if (h === b.ownerHandle) continue; 509 const p = this.players.get(h); 510 if (!p || !p.alive) continue; 511 512 const dx = b.x - p.x; 513 const dy = b.y - p.y; 514 if (dx * dx + dy * dy < HIT_R * HIT_R) { 515 // Hit! Server-authoritative kill 516 this.bullets.splice(i, 1); 517 this.endRound(b.ownerHandle); 518 return; // only one kill per tick 519 } 520 } 521 } 522 } 523 524 // -- Snapshot Broadcasting -- 525 526 broadcastSnapshot() { 527 const duelists = this.getDuelists(); 528 const playersData = duelists.map((h) => { 529 const p = this.players.get(h); 530 if (!p) return null; 531 return { 532 handle: h, 533 x: Math.round(p.x * 10) / 10, 534 y: Math.round(p.y * 10) / 10, 535 targetX: Math.round(p.targetX * 10) / 10, 536 targetY: Math.round(p.targetY * 10) / 10, 537 alive: p.alive, 538 ping: p.ping, 539 }; 540 }).filter(Boolean); 541 542 const bulletsData = this.bullets.map((b) => ({ 543 x: Math.round(b.x * 10) / 10, 544 y: Math.round(b.y * 10) / 10, 545 vx: b.vx, 546 vy: b.vy, 547 owner: b.ownerHandle, 548 age: b.age, 549 })); 550 551 const lastInputSeq = {}; 552 for (const h of duelists) { 553 const p = this.players.get(h); 554 if (p) lastInputSeq[h] = p.lastInputSeq; 555 } 556 557 const snapshot = { 558 tick: this.tick, 559 phase: this.phase, 560 countdownTimer: this.countdownTimer, 561 roundOverTimer: this.roundOverTimer, 562 roundWinner: this.roundWinner, 563 players: playersData, 564 bullets: bulletsData, 565 roster: this.roster, 566 lastInputSeq, 567 }; 568 569 const data = JSON.stringify(snapshot); 570 571 // Log periodic snapshot info + always log when bullets present 572 if (this.tick % 300 === 0 || bulletsData.length > 0) { 573 const channels = []; 574 for (const [h, p] of this.players) { 575 if (h === DUMMY_HANDLE) continue; 576 channels.push(`${h}:${p.udpChannelId ? "UDP" : p.wsId != null ? "WS" : "NONE"}`); 577 } 578 console.log(`🎯 Duel snapshot #${this.tick} phase=${this.phase} bullets=${bulletsData.length} via [${channels.join(", ")}]`); 579 } 580 581 // Always send via WS for reliability (UDP was silently dropping packets) 582 for (const [handle, player] of this.players) { 583 if (handle === DUMMY_HANDLE) continue; 584 if (player.wsId != null && this.sendWS) { 585 this.sendWS(player.wsId, "duel:snapshot", snapshot); 586 } 587 } 588 // Send to spectators too 589 for (const [, spec] of this.spectators) { 590 if (spec.wsId != null && this.sendWS) { 591 this.sendWS(spec.wsId, "duel:snapshot", snapshot); 592 } 593 } 594 } 595}