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".
443 lines
13 KiB
TypeScript
443 lines
13 KiB
TypeScript
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 })
|
||
}
|