feat: initialize TRXTD browser tower defense

This commit is contained in:
Tronax 2026-08-15 16:07:09 +02:00
commit c4347f8420
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
34 changed files with 8302 additions and 0 deletions

404
src/game/mpgame.ts Normal file
View file

@ -0,0 +1,404 @@
import { GameEngine, engine } from './engine'
import { MPClient, serverUrl } from './net'
import { sound } from './sound'
import { store } from './store'
import type { 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; players: { id: number; name: string }[]; you: number }): void {
store.mp.roomCode = info.code
store.mp.mode = info.mode
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): 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.createRoom(mode, name)
} catch (err) {
store.mp.status = err instanceof Error ? err.message : 'Verbindung fehlgeschlagen.'
} finally {
this.connecting = false
}
}
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; 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)
if (info.mode === 'coop') {
engine.startGame('normal')
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.startGame('normal')
engine.mpGameActive = true
engine.mpMode = 'duel'
engine.localPlayerId = info.you
engine.mpController = this
const remote = new GameEngine()
remote.startGame('normal')
remote.mpGameActive = true
remote.mpMode = 'duel'
remote.localPlayerId = 1 - info.you
remote.ghost = true
this.engines = [engine, remote]
this.remoteEngine = remote
}
store.mp.active = true
store.mp.mode = info.mode
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)
engine.announce(
info.mode === 'coop' ? 'Coop-Modus!' : '1v1-Duell!',
info.mode === 'coop' ? 'Gemeinsames Gold Teamwork!' : 'Verteidige deine Basis!',
)
}
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 20 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,
}
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 })
}