feat: initialize TRXTD browser tower defense
This commit is contained in:
commit
c4347f8420
34 changed files with 8302 additions and 0 deletions
404
src/game/mpgame.ts
Normal file
404
src/game/mpgame.ts
Normal 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 })
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue