feat: initialize TRXTD browser tower defense
This commit is contained in:
commit
c4347f8420
34 changed files with 8302 additions and 0 deletions
163
scripts/sim.mts
Normal file
163
scripts/sim.mts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
/**
|
||||
* Headless balance simulation: a greedy bot plays the game on 'normal'
|
||||
* to verify the campaign is winnable (and not trivially easy).
|
||||
*
|
||||
* Run: npm run sim
|
||||
*/
|
||||
import { GameEngine } from '../src/game/engine.ts'
|
||||
import type { TowerKind } from '../src/game/types.ts'
|
||||
|
||||
const eng = new GameEngine()
|
||||
eng.startGame('normal')
|
||||
|
||||
// predetermined build spots (tile coords) near path hot spots
|
||||
const spots: [number, number][] = [
|
||||
[8, 5], // center between the two long vertical runs
|
||||
[3, 0],
|
||||
[7, 0],
|
||||
[11, 0],
|
||||
[4, 4],
|
||||
[12, 6],
|
||||
[8, 8],
|
||||
[8, 10],
|
||||
[12, 10],
|
||||
[16, 3],
|
||||
[18, 4],
|
||||
[15, 0],
|
||||
[4, 7],
|
||||
[8, 2],
|
||||
[12, 3],
|
||||
[7, 10],
|
||||
[13, 10],
|
||||
[1, 4],
|
||||
[18, 6],
|
||||
[16, 6],
|
||||
[5, 0],
|
||||
[9, 0],
|
||||
[13, 0],
|
||||
[8, 6],
|
||||
[12, 7],
|
||||
[4, 2],
|
||||
]
|
||||
|
||||
// what to build in which order (afterwards: upgrades only)
|
||||
const plan: TowerKind[] = [
|
||||
'arrow',
|
||||
'frost',
|
||||
'arrow',
|
||||
'cannon',
|
||||
'arrow',
|
||||
'tesla',
|
||||
'frost',
|
||||
'arrow',
|
||||
'cannon',
|
||||
'laser',
|
||||
'tesla',
|
||||
'laser',
|
||||
'frost',
|
||||
'cannon',
|
||||
'tesla',
|
||||
'arrow',
|
||||
'laser',
|
||||
'frost',
|
||||
'cannon',
|
||||
'tesla',
|
||||
'laser',
|
||||
'arrow',
|
||||
'arrow',
|
||||
'arrow',
|
||||
'arrow',
|
||||
'arrow',
|
||||
]
|
||||
|
||||
function bot(): void {
|
||||
// spend greedily: cheapest sensible action first, repeat
|
||||
for (;;) {
|
||||
let acted = false
|
||||
|
||||
// next planned build on the next free spot
|
||||
const built = eng.towers.length
|
||||
if (built < plan.length) {
|
||||
const kind = plan[built]
|
||||
const spot = spots[built % spots.length]
|
||||
const cost = towerCost(kind)
|
||||
if (eng.money >= cost && eng.canPlace(spot[0], spot[1])) {
|
||||
if (!eng.placeTower(kind, spot[0], spot[1])) throw new Error('place failed at ' + spot)
|
||||
acted = true
|
||||
continue
|
||||
}
|
||||
// spot blocked -> skip this spot for that build
|
||||
if (!eng.canPlace(spot[0], spot[1])) {
|
||||
// try any other free spot
|
||||
const alt = spots.find((s) => eng.canPlace(s[0], s[1]))
|
||||
if (alt && eng.money >= cost) {
|
||||
eng.placeTower(kind, alt[0], alt[1])
|
||||
acted = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cheapest upgrade among towers (max 3 levels)
|
||||
let bestCost = Infinity
|
||||
let bestT: ReturnType<typeof eng.towers.at> = undefined
|
||||
for (const t of eng.towers) {
|
||||
const c = upgradeCostOf(t.kind, t.level)
|
||||
if (c !== null && c < bestCost) {
|
||||
bestCost = c
|
||||
bestT = t
|
||||
}
|
||||
}
|
||||
if (bestT && bestCost <= eng.money) {
|
||||
eng.upgradeTower(bestT)
|
||||
acted = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (!acted) break
|
||||
}
|
||||
|
||||
// start next wave early for the bonus, like an aggressive player
|
||||
if (eng.phase === 'intermission') eng.startWave(true)
|
||||
}
|
||||
|
||||
function towerCost(kind: TowerKind): number {
|
||||
const costs: Record<TowerKind, number> = { arrow: 50, cannon: 110, frost: 80, tesla: 130, laser: 160 }
|
||||
return costs[kind]
|
||||
}
|
||||
|
||||
function upgradeCostOf(kind: TowerKind, level: number): number | null {
|
||||
const up: Record<TowerKind, [number, number]> = {
|
||||
arrow: [60, 110],
|
||||
cannon: [100, 180],
|
||||
frost: [70, 130],
|
||||
tesla: [120, 200],
|
||||
laser: [150, 260],
|
||||
}
|
||||
if (level >= 3) return null
|
||||
return up[kind][level - 1]
|
||||
}
|
||||
|
||||
let lastWave = 0
|
||||
let guard = 0
|
||||
while (eng.phase !== 'gameover' && eng.phase !== 'victory' && guard < 60 * 60 * 60) {
|
||||
eng.update(1 / 60)
|
||||
bot()
|
||||
if (eng.waveNo !== lastWave) {
|
||||
lastWave = eng.waveNo
|
||||
const kindCounts: Record<string, number> = {}
|
||||
for (const t of eng.towers) kindCounts[t.kind + t.level] = (kindCounts[t.kind + t.level] ?? 0) + 1
|
||||
console.log(
|
||||
`Welle ${String(eng.waveNo).padStart(2)} | ❤ ${String(eng.lives).padStart(2)} | 🪙 ${String(Math.floor(eng.money)).padStart(4)} | Türme: ${eng.towers.length} ${JSON.stringify(kindCounts)}`,
|
||||
)
|
||||
}
|
||||
guard++
|
||||
}
|
||||
|
||||
console.log('---')
|
||||
console.log(`Ergebnis: ${eng.phase === 'victory' ? 'SIEG' : 'NIEDERLAGE'} in Welle ${eng.waveNo}, Leben übrig: ${eng.lives}, Punkte: ${eng.score}`)
|
||||
if (eng.phase !== 'victory') {
|
||||
console.log(' → Balancing zu schwer! Gegner-Werte abschwächen.')
|
||||
} else if (eng.lives > 14) {
|
||||
console.log(' → Balancing zu leicht (zu viele Leben übrig).')
|
||||
}
|
||||
72
scripts/test-mp.mts
Normal file
72
scripts/test-mp.mts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/**
|
||||
* Lockstep determinism check: two engines receiving the same actions at the
|
||||
* same ticks must produce identical game state (the foundation of multiplayer
|
||||
* without state sync).
|
||||
*/
|
||||
import { GameEngine } from '../src/game/engine.ts'
|
||||
|
||||
const e1 = new GameEngine()
|
||||
const e2 = new GameEngine()
|
||||
for (const e of [e1, e2]) {
|
||||
e.startGame('normal')
|
||||
e.mpGameActive = true
|
||||
e.mpMode = 'coop'
|
||||
e.coopHpMul = 1.7
|
||||
}
|
||||
|
||||
// scripted action stream (tick, action) – like the relayed server order
|
||||
const script: [number, Parameters<GameEngine['applyAction']>[0]][] = [
|
||||
[5, { type: 'build', kind: 'arrow', tx: 8, ty: 5 }],
|
||||
[5, { type: 'build', kind: 'frost', tx: 3, ty: 0 }],
|
||||
[10, { type: 'wave' }],
|
||||
[200, { type: 'build', kind: 'cannon', tx: 7, ty: 0 }],
|
||||
[400, { type: 'upgrade', towerId: 1 }],
|
||||
[400, { type: 'build', kind: 'tesla', tx: 12, ty: 6 }],
|
||||
[700, { type: 'wave' }],
|
||||
[900, { type: 'obstacle', tx: 19, ty: 0 }],
|
||||
[1200, { type: 'build', kind: 'laser', tx: 18, ty: 4 }],
|
||||
[1500, { type: 'speed', s: 2 }],
|
||||
[1800, { type: 'wave' }],
|
||||
]
|
||||
|
||||
let idx = 0
|
||||
const TOTAL_TICKS = 3600 // ~2 Minuten Spielzeit @30 Ticks/s
|
||||
let p0: ReturnType<GameEngine['applyAction']> = true
|
||||
let p1 = p0
|
||||
|
||||
for (let tick = 1; tick <= TOTAL_TICKS; tick++) {
|
||||
while (idx < script.length && script[idx][0] <= tick) {
|
||||
const a = script[idx][1]
|
||||
p0 = e1.applyAction(a, idx % 2) // alternate acting players
|
||||
p1 = e2.applyAction(a, idx % 2)
|
||||
idx++
|
||||
}
|
||||
e1.update(1 / 30)
|
||||
e2.update(1 / 30)
|
||||
}
|
||||
|
||||
function state(e: GameEngine): string {
|
||||
return JSON.stringify({
|
||||
money: Math.floor(e.money * 1000),
|
||||
lives: e.lives,
|
||||
score: e.score,
|
||||
kills: e.kills,
|
||||
wave: e.waveNo,
|
||||
phase: e.phase,
|
||||
enemies: e.enemies.map((x) => [x.id, Math.round(x.hp * 1000), x.wp]),
|
||||
towers: e.towers.map((t) => [t.id, t.kind, t.level, t.kills, Math.round(t.damageDealt)]),
|
||||
queue: e.spawnQueue.map((s) => [Math.round(s.t * 1000), s.kind]),
|
||||
blocked: e.blockedCells.size,
|
||||
})
|
||||
}
|
||||
|
||||
const s1 = state(e1)
|
||||
const s2 = state(e2)
|
||||
console.log('Aktionen ausgeführt:', idx, '| identisch:', p0 === p1)
|
||||
console.log('Zustand identisch:', s1 === s2)
|
||||
if (s1 !== s2) {
|
||||
console.log('e1:', s1.slice(0, 400))
|
||||
console.log('e2:', s2.slice(0, 400))
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`OK: beide Engines identisch nach ${TOTAL_TICKS} Ticks | Welle ${e1.waveNo} | ❤ ${e1.lives} | 🪙 ${Math.floor(e1.money)} | Türme ${e1.towers.length}`)
|
||||
43
scripts/test-obstacle.mts
Normal file
43
scripts/test-obstacle.mts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { GameEngine } from '../src/game/engine.ts'
|
||||
import { store } from '../src/game/store.ts'
|
||||
|
||||
const eng = new GameEngine()
|
||||
eng.startGame('normal')
|
||||
|
||||
// irgendein blockierendes Hindernis finden
|
||||
const blocked = [...eng.blockedCells]
|
||||
console.log('blockierte Zellen:', blocked.length)
|
||||
const [tx, ty] = blocked[0].split(',').map(Number)
|
||||
const item = eng.obstacleAt(tx, ty)!
|
||||
console.log('Hindernis:', item.type, 'auf', tx, ty, '| canPlace davor:', eng.canPlace(tx, ty))
|
||||
|
||||
// per Klick auswählen (Klick auf Zellmitte); store wird in step() synchronisiert
|
||||
eng.click(tx * 48 + 24, ty * 48 + 24)
|
||||
eng.step(0)
|
||||
console.log('Panel offen:', store.obstacle !== null, '| Typ:', store.obstacle?.type, '| Kosten:', store.obstacle?.cost)
|
||||
|
||||
// zu wenig Gold -> Ablehnung
|
||||
eng.money = 10
|
||||
eng.removeSelectedObstacle()
|
||||
console.log('abgelehnt bei 10 Gold: Panel noch offen =', store.obstacle !== null, '| Gold =', eng.money)
|
||||
|
||||
// genug Gold -> Entfernen klappt
|
||||
eng.money = 500
|
||||
eng.removeSelectedObstacle()
|
||||
eng.step(0)
|
||||
console.log(
|
||||
'entfernt: Panel zu =', store.obstacle === null,
|
||||
'| Gold -' + (500 - eng.money),
|
||||
'| canPlace danach:', eng.canPlace(tx, ty),
|
||||
'| Dekor weg:', eng.obstacleAt(tx, ty) === undefined,
|
||||
)
|
||||
|
||||
// Turm auf freigewordener Zelle bauen
|
||||
console.log('Turmbau auf Zelle:', eng.placeTower('arrow', tx, ty))
|
||||
|
||||
// Neustart stellt Hindernisse wieder her
|
||||
eng.startGame('normal')
|
||||
console.log(
|
||||
'nach Neustart wieder blockiert:', eng.blockedCells.has(tx + ',' + ty),
|
||||
'| Anzahl wie vorher:', eng.blockedCells.size === blocked.length,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue