import { GameEngine, engine } from './engine' import { MPClient, serverUrl } from './net' import { sound } from './sound' import { store } from './store' import type { MapId, MPAction, MPMode, TargetingMode } from './types' /** simulation tick rate in multiplayer (both clients advance identical ticks) */ const TICK_DT = 1 / 30 /** actions are applied DELAY ticks after sending so both clients hold them in order */ const DELAY_TICKS = 3 /** coop: enemies are tougher because two players defend together */ const COOP_HP_MUL = 1.7 /** 1v1 rush pricing */ const RUSH_BASE_COST = 60 const RUSH_COST_PER_WAVE = 8 interface InboxEntry { seq: number from: number tick: number a: MPAction } /** * Multiplayer controller. * * Coop: one shared engine – both players build on the same board with a shared * gold pot and shared lives (towers belong to their builder). * Duel (1v1): each player defends their own board; both boards are simulated * on both clients (deterministic lockstep) so you can watch the opponent live. */ class MpGameController { private net = new MPClient({ onRoom: (info) => this.handleRoom(info), onStart: (info) => this.handleStart(info), onAct: (msg) => { // guard inbox against excessive queuing if (this.inbox.length < 500) { this.inbox.push(msg) } }, onProg: (_from, tick) => { if (tick > this.net.remoteTick) this.net.remoteTick = tick }, onPeerLeft: () => this.handlePeerLeft(), onError: (msg) => this.setLobbyError(msg), }) active = false mode: MPMode = 'coop' engines: GameEngine[] = [] remoteEngine: GameEngine | null = null myId = 0 private myTick = 0 private acc = 0 private inbox: InboxEntry[] = [] private heartbeat: ReturnType | null = null private ended = false private connecting = false get isHost(): boolean { return store.mp.myId === 0 } // ---------------------------------------------------------------- lobby private setLobbyError(msg: string): void { if (!this.active) { store.mp.status = msg store.mp.inLobby = false store.screen = 'menu' this.net.close() } } private handleRoom(info: { code: string; mode: MPMode; mapId?: MapId; players: { id: number; name: string }[]; you: number }): void { store.mp.roomCode = info.code store.mp.mode = info.mode store.mp.mapId = info.mapId || 'meadow' store.mp.players = info.players store.mp.myId = info.you store.mp.isHost = info.you === 0 store.mp.inLobby = true store.mp.status = '' store.screen = 'lobby' } async create(mode: MPMode, name: string, mapId: MapId = 'meadow'): Promise { if (this.connecting) return this.connecting = true store.mp.status = 'Verbinde mit Server…' try { await this.net.connect(serverUrl()) store.mp.name = name store.mp.mapId = mapId store.mp.active = false this.net.createRoom(mode, name, mapId) } catch (err) { store.mp.status = err instanceof Error ? err.message : 'Verbindung fehlgeschlagen.' } finally { this.connecting = false } } setMap(mapId: MapId): void { if (store.mp.isHost) { store.mp.mapId = mapId this.net.setMap(mapId) } } async join(code: string, name: string): Promise { if (this.connecting) return this.connecting = true store.mp.status = 'Verbinde mit Server…' try { await this.net.connect(serverUrl()) store.mp.name = name store.mp.active = false this.net.joinRoom(code, name) } catch (err) { store.mp.status = err instanceof Error ? err.message : 'Verbindung fehlgeschlagen.' } finally { this.connecting = false } } requestStart(): void { this.net.startGame() } leave(): void { this.disposeGame() this.net.leave() this.resetStoreMp() store.screen = 'menu' store.result = null } private resetStoreMp(): void { store.mp.active = false store.mp.inLobby = false store.mp.status = '' store.mp.roomCode = '' store.mp.players = [] store.mp.resultMsg = '' } // ---------------------------------------------------------------- game start private handleStart(info: { you: number; mode: MPMode; mapId?: MapId; players: { id: number; name: string }[] }): void { this.active = true this.ended = false this.mode = info.mode this.myId = info.you this.myTick = 0 this.acc = 0 this.inbox = [] const me = info.players.find((p) => p.id === info.you) const peer = info.players.find((p) => p.id !== info.you) const chosenMap = info.mapId || store.mp.mapId || 'meadow' if (info.mode === 'coop') { engine.setMeta({}) // no meta bonuses in multiplayer (fairness + determinism) engine.startGame('normal', chosenMap) engine.mpGameActive = true engine.mpMode = 'coop' engine.localPlayerId = info.you engine.coopHpMul = COOP_HP_MUL engine.mpController = this this.engines = [engine] this.remoteEngine = null } else { engine.setMeta({}) engine.startGame('normal', chosenMap) engine.mpGameActive = true engine.mpMode = 'duel' engine.localPlayerId = info.you engine.mpController = this const remote = new GameEngine() remote.setMeta({}) remote.startGame('normal', chosenMap) remote.mpGameActive = true remote.mpMode = 'duel' remote.localPlayerId = 1 - info.you remote.ghost = true // CRITICAL: index boards by PLAYER ID so engines[from] is always the // board owned by the acting player on every client this.engines = [engine, remote] this.engines[info.you] = engine this.engines[1 - info.you] = remote this.remoteEngine = remote } store.mp.active = true store.mp.mode = info.mode store.mp.mapId = chosenMap store.mp.name = me?.name ?? 'Du' store.mp.peerName = peer?.name ?? 'Gegner' store.mp.myId = info.you store.mp.resultMsg = '' store.mp.opponentLives = this.remoteEngine ? this.remoteEngine.lives : 0 store.screen = 'game' store.paused = false sound.play('wave') this.net.remoteTick = -1 if (this.heartbeat) clearInterval(this.heartbeat) this.heartbeat = setInterval(() => this.net.sendProgress(this.myTick), 100) store.mp.viewOpponent = false engine.announce( info.mode === 'coop' ? 'Coop-Modus!' : '1v1-Duell!', info.mode === 'coop' ? 'Gemeinsames Gold – Teamwork!' : 'Dies ist DEIN Feld – Gegner live im Mini-Fenster', ) } /** duel: is the opponent's board currently shown on the main canvas? */ get viewSwap(): boolean { return store.mp.viewOpponent && this.mode === 'duel' && this.remoteEngine !== null } /** engine whose board is rendered on the big canvas */ mainView(): GameEngine { return this.viewSwap && this.remoteEngine ? this.remoteEngine : engine } toggleView(): void { if (this.mode === 'duel' && this.remoteEngine) { store.mp.viewOpponent = !store.mp.viewOpponent engine.cancelMode() } } private disposeGame(): void { if (this.heartbeat) clearInterval(this.heartbeat) this.heartbeat = null for (const e of this.engines) { e.mpController = null e.mpGameActive = false e.mpMode = 'solo' e.coopHpMul = 1 e.ghost = false e.localPlayerId = 0 e.setSpeedLocal(1) } this.engines = [] this.remoteEngine = null this.active = false this.ended = true this.inbox = [] } private handlePeerLeft(): void { if (this.active && !this.ended) { const msg = this.mode === 'coop' ? 'Dein Mitspieler hat das Spiel verlassen.' : 'Gegner hat verlassen – du gewinnst!' this.endGame(this.mode === 'duel', msg) } else if (store.mp.inLobby) { // peer left the lobby: back to room view with only us store.mp.players = store.mp.players.filter((p) => p.id === store.mp.myId) } } // ---------------------------------------------------------------- lockstep loop /** called from engine.step() every animation frame */ frame(dtReal: number): void { if (!this.active || this.ended) return this.acc = Math.min(this.acc + dtReal * engine.speed, 0.4) while (this.acc >= TICK_DT) { this.acc -= TICK_DT this.myTick++ const due = this.inbox .filter((m) => m.tick <= this.myTick) .sort((p, q) => p.tick - q.tick || p.seq - q.seq) this.inbox = this.inbox.filter((m) => m.tick > this.myTick) for (const m of due) this.route(m.a, m.from) for (const e of this.engines) e.update(TICK_DT) } this.syncDuelInfo() this.checkEnd() } /** applies an action on the right board(s) – must behave identically on all clients */ private route(a: MPAction, from: number): void { if (a.type === 'rush') { this.doRush(from) return } if (a.type === 'speed') { for (const e of this.engines) e.setSpeedLocal(a.s) return } if (this.mode === 'coop') { this.engines[0].applyAction(a, from) return } if (a.type === 'wave') { // duel: waves always start on BOTH boards (stays symmetric) for (const e of this.engines) e.applyAction(a, from) return } this.engines[from]?.applyAction(a, from) } private doRush(from: number): void { const sender = this.engines[from] const target = this.engines[1 - from] if (!sender || !target) return const cost = this.rushCost(sender) if (sender.money < cost) { if (from === this.myId) sound.play('error') return } sender.money -= cost target.spawnRush() } rushCost(e: GameEngine): number { return RUSH_BASE_COST + e.waveNo * RUSH_COST_PER_WAVE } private syncDuelInfo(): void { if (this.mode !== 'duel' || !this.remoteEngine) return store.mp.opponentLives = this.remoteEngine.lives store.mp.opponentGold = Math.floor(this.remoteEngine.money) store.mp.opponentWave = this.remoteEngine.waveNo store.mp.rushCost = this.rushCost(engine) } // ---------------------------------------------------------------- end conditions private checkEnd(): void { if (this.ended) return if (this.mode === 'coop') { if (engine.phase === 'gameover') this.endGame(false, 'Eure Basis ist gefallen…') else if (engine.phase === 'victory') this.endGame(true, 'Alle 30 Wellen gemeinsam geschafft!') return } const mine = this.engines[this.myId] const theirs = this.engines[1 - this.myId] if (!mine || !theirs) return if (mine.phase === 'gameover') { this.endGame(false, `${store.mp.peerName} hat deine Basis zerstört.`) } else if (theirs.phase === 'gameover') { this.endGame(true, `Basis von ${store.mp.peerName} zerstört – du gewinnst!`) } else if (mine.phase === 'victory' && theirs.phase === 'victory') { if (mine.lives > theirs.lives) this.endGame(true, 'Nach 20 Wellen hast du mehr Leben übrig!') else if (theirs.lives > mine.lives) this.endGame(false, 'Nach 20 Wellen hat der Gegner mehr Leben übrig.') else this.endGame(mine.score >= theirs.score, 'Gleichstand nach Leben – die Punkte entscheiden!') } } private endGame(win: boolean, msg: string): void { this.ended = true if (this.heartbeat) clearInterval(this.heartbeat) this.heartbeat = null store.mp.resultMsg = msg store.result = { win, score: engine.score, wave: engine.waveNo, kills: engine.kills, best: 0, bestBefore: 0, crystalsEarned: 0, } store.screen = win ? 'victory' : 'gameover' sound.play(win ? 'victory' : 'defeat') } // ---------------------------------------------------------------- outgoing actions send(a: MPAction): void { // immediate local precheck for responsive error feedback; the authoritative // application happens when the server-relayed action reaches its tick if (a.type === 'build') { const ok = engine.canPlace(a.tx, a.ty) && engine.money >= 50 // cost check repeated on apply if (!ok) { sound.play('error') return } } this.net.sendAction(a, this.myTick + DELAY_TICKS) } } export const mpgame = new MpGameController() // ------------------------------------------------------------------ UI facade // Components call these; they route to the network in multiplayer and to the // engine directly in solo mode. export function submitAction(a: MPAction): void { if (mpgame.active) mpgame.send(a) else engine.applyLocal(a) } export function uiStartWaveEarly(): void { if (mpgame.active) mpgame.send({ type: 'wave' }) else engine.startWave(true) } export function uiSetSpeed(s: number): void { if (mpgame.active) mpgame.send({ type: 'speed', s }) else engine.setSpeed(s) } export function uiTogglePause(): void { if (!mpgame.active) engine.togglePause() } export function uiUpgradeSelected(): void { const t = engine.selectedTower() if (!t) return submitAction({ type: 'upgrade', towerId: t.id }) } export function uiSellSelected(): void { const t = engine.selectedTower() if (!t) return submitAction({ type: 'sell', towerId: t.id }) } export function uiSetTargeting(mode: TargetingMode): void { const t = engine.selectedTower() if (!t) return submitAction({ type: 'targeting', towerId: t.id, mode }) } export function uiRemoveObstacle(): void { const ob = engine.selectedObstacle if (!ob) return submitAction({ type: 'obstacle', tx: ob.tx, ty: ob.ty }) }