TRXTD/src/game/mpgame.ts
Tronax aa68343ac2
feat(balance): 30-wave campaign, 5 new enemy types, steeper per-wave scaling
The campaign was too easy: a handful of un-upgraded towers stopped
everything. This makes the game substantially harder across three axes.

Campaign extended from 20 to 30 hand-tuned waves (endless now starts at
31). Victory messages, HUD label (via store.totalWaves synced from
TOTAL_WAVES), start screen, lobby and README updated.

Five new enemy types debut in later waves, each with a hand-drawn
canvas look:
- Brute (wave 8): fast tank hybrid, 130 HP, 2 lives
- Phantom (wave 12): fast flyer with real HP, 2 lives
- Scorpion (wave 14): very fast ground, 2 lives
- Golem (wave 18): walking bunker, 560 HP, 3 lives
- Dragon (wave 22): flying boss, 950 HP, 3 lives, gets its own top
  boss health bar like the boss

Per-wave HP scaling steepened (1 + 0.16m + 0.025m², was 0.18m + 0.02m²):
wave 10 now ~4.6x (was 4.2x), wave 20 ~13.2x (was 11.6x), wave 30 ~29x.
Late waves mix the new types into heavy compositions; wave 30 is the
final wall (3 bosses, 2 dragons, 4 golems, 12 scorpions). Endless waves
scale all ten types with bosses every 5 and dragons from endless+2.

Balance validated with the headless greedy-bot simulation: the bot
(capped around tower level 5 on fixed spots) previously trivially won
the campaign and now dies at wave 29 — clearing wave 30 requires
evolved towers (level 6+), research bonuses and good placement. The
sim docs were updated to describe this new tuning philosophy.

New render smoke test (npm test): draws all ten enemy kinds through
the real renderer with a stub 2D context, two frames each with slow/
DoT/flash effects active, guarding every drawEnemy code path. 55 checks
green, build clean. Browser-verified: campaign starts and HUD shows
"Welle x/30".
2026-08-17 20:29:47 +02:00

443 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<typeof setInterval> | 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<void> {
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<void> {
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 })
}