feat: add multi-map system with 4 distinct biomes, layouts, and multiplayer sync
- Introduce 4 playable map environments: * Meadow (Grüne Lichtung): Classic balanced S-curve path with lush grasslands, flowers, and standard obstacles * Desert (Sonnendünen): Winding dune trail with sandstone rocks, oasis palms, and scrub * Frostland (Frostgipfel): Icy serpentine path through snowfields with frost-covered pines and crystal rocks * Volcano (Lavabruch): Tight aggressive layout through dark obsidian/basalt with glowing magma streams and floating ember sparks - Add comprehensive MapDef & MapTheme configuration for distinct waypoint routes, flying trajectories, biome color palettes, portal/keep styles, and obstacle densities - Extend Canvas 2D renderer to dynamically adapt backgrounds, path texturing, portal effects, and procedurally drawn biome foliage/decorations per map - Upgrade GameEngine with dynamic map loading, obstacle clearing logic, and per-map highscore persistence - Implement multiplayer map synchronization in WebSocket server (set-map event), room state, and lobby UI allowing host map selection - Update StartScreen and LobbyScreen components with interactive map selection cards and per-map highscore tracking - Add automated test suites for map path integrity, determinism, playability, and room map protocol synchronization
This commit is contained in:
parent
a7fb3efa61
commit
ce2484bb8d
13 changed files with 911 additions and 132 deletions
70
scripts/test-map-room.mjs
Normal file
70
scripts/test-map-room.mjs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* Smoke test for the map-aware multiplayer room protocol:
|
||||
* create with a map, host changes the map, join sees the map, start carries it.
|
||||
* Requires the server: PORT=3102 node server/server.mjs
|
||||
*/
|
||||
import { WebSocket } from 'ws'
|
||||
|
||||
const PORT = 3102
|
||||
const URL = `ws://localhost:${PORT}`
|
||||
|
||||
function connect(origin) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(URL, origin ? { origin } : undefined)
|
||||
const queue = []
|
||||
const waiters = []
|
||||
ws.on('open', () => resolve({
|
||||
ws,
|
||||
next: () => new Promise((res) => {
|
||||
const m = queue.shift()
|
||||
if (m) return res(m)
|
||||
waiters.push(res)
|
||||
}),
|
||||
close: () => ws.close(),
|
||||
}))
|
||||
ws.on('message', (d) => {
|
||||
const m = JSON.parse(d.toString())
|
||||
const w = waiters.shift()
|
||||
if (w) w(m)
|
||||
else queue.push(m)
|
||||
})
|
||||
ws.on('error', reject)
|
||||
setTimeout(() => reject(new Error('timeout')), 4000)
|
||||
})
|
||||
}
|
||||
|
||||
const host = await connect('http://localhost:' + PORT)
|
||||
host.ws.send(JSON.stringify({ t: 'create', mode: 'duel', name: 'Host', mapId: 'volcano' }))
|
||||
const room = await host.next()
|
||||
console.log('1) Raum erstellt mit Karte :', room.t === 'room' && room.mapId === 'volcano' ? 'OK (volcano)' : `FEHLER (${JSON.stringify(room)})`)
|
||||
|
||||
const guest = await connect('http://localhost:' + PORT)
|
||||
guest.ws.send(JSON.stringify({ t: 'join', code: room.code, name: 'Gast' }))
|
||||
const join = await guest.next()
|
||||
const joinHost = await host.next()
|
||||
console.log('2) Gast sieht Karte :', join.t === 'room' && join.mapId === 'volcano' ? 'OK' : `FEHLER (${JSON.stringify(join)})`)
|
||||
console.log('3) Host-Sync bleibt Karte :', joinHost.t === 'room' && joinHost.mapId === 'volcano' ? 'OK' : `FEHLER (${JSON.stringify(joinHost)})`)
|
||||
|
||||
// guest may NOT change the map
|
||||
guest.ws.send(JSON.stringify({ t: 'set-map', mapId: 'desert' }))
|
||||
await new Promise((r) => setTimeout(r, 300))
|
||||
console.log('4) Gast darf Karte nicht ändern: OK (Server ignoriert)')
|
||||
|
||||
host.ws.send(JSON.stringify({ t: 'set-map', mapId: 'frostland' }))
|
||||
const m1 = await host.next()
|
||||
const m2 = await guest.next()
|
||||
console.log('5) Host wechselt Karte :', m1.mapId === 'frostland' && m2.mapId === 'frostland' ? 'OK (frostland)' : `FEHLER (${m1.mapId}/${m2.mapId})`)
|
||||
|
||||
host.ws.send(JSON.stringify({ t: 'start' }))
|
||||
const s1 = await host.next()
|
||||
const s2 = await guest.next()
|
||||
console.log('6) Start trägt Karte :', s1.t === 'start' && s1.mapId === 'frostland' && s2.mapId === 'frostland' ? 'OK' : `FEHLER (${JSON.stringify([s1, s2])})`)
|
||||
|
||||
// invalid map id must fall back to meadow
|
||||
const host2 = await connect('http://localhost:' + PORT)
|
||||
host2.ws.send(JSON.stringify({ t: 'create', mode: 'coop', name: 'H2', mapId: 'hack' }))
|
||||
const r2 = await host2.next()
|
||||
console.log('7) Ungültige Karte -> Wiese :', r2.mapId === 'meadow' ? 'OK' : `FEHLER (${r2.mapId})`)
|
||||
|
||||
host.close(); guest.close(); host2.close()
|
||||
process.exit(0)
|
||||
165
scripts/test-maps.mts
Normal file
165
scripts/test-maps.mts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
/**
|
||||
* Map system verification:
|
||||
* - every map builds a valid, non-empty path & fly path
|
||||
* - path cells never collide with blocked (obstacle) cells
|
||||
* - maps differ from each other (distinct layouts)
|
||||
* - lockstep determinism holds on every map (two engines, same actions)
|
||||
* - obstacle clearing frees build tiles on every map
|
||||
* - a full 20-wave simulation is beatable/playable on every map
|
||||
*/
|
||||
import { GameEngine } from '../src/game/engine.ts'
|
||||
import { MAPS, MAP_ORDER } from '../src/game/config.ts'
|
||||
import type { MapId } from '../src/game/types.ts'
|
||||
|
||||
let failures = 0
|
||||
function check(label: string, ok: boolean, detail = ''): void {
|
||||
if (!ok) {
|
||||
failures++
|
||||
console.error(` ✗ ${label}${detail ? ` — ${detail}` : ''}`)
|
||||
} else {
|
||||
console.log(` ✓ ${label}`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const mapId of MAP_ORDER) {
|
||||
const map = MAPS[mapId]
|
||||
console.log(`\n=== ${map.name} (${mapId}) ===`)
|
||||
|
||||
const eng = new GameEngine()
|
||||
eng.setMap(mapId)
|
||||
eng.startGame('normal', mapId)
|
||||
|
||||
// 1. path built & sane
|
||||
check('Wegpunkte vorhanden', eng.pathPx.length >= 2)
|
||||
check('Flugweg vorhanden', eng.flyPathPx.length >= 2)
|
||||
check('Pfadzellen markiert', eng.pathCells.size > 0)
|
||||
|
||||
const wp = map.waypoints
|
||||
const interior = wp.slice(1, -1)
|
||||
check(
|
||||
'Innere Wegpunkte im Spielfeld',
|
||||
interior.every(([x, y]) => x >= 0 && x < 20 && y >= 0 && y < 11),
|
||||
JSON.stringify(wp),
|
||||
)
|
||||
check(
|
||||
'Start/Ende am Spielfeldrand',
|
||||
wp.every(([x, y]) => x === -1 || x === 20 || y >= 0),
|
||||
)
|
||||
|
||||
// 2. no collision between path and obstacles
|
||||
const overlap = [...eng.pathCells].filter((k) => eng.blockedCells.has(k))
|
||||
check('Keine Hindernisse auf dem Weg', overlap.length === 0, overlap.join(','))
|
||||
|
||||
// 3. obstacles exist and are removable
|
||||
const obstacleCells = [...eng.blockedCells]
|
||||
check('Hindernisse vorhanden', obstacleCells.length >= 8, `${obstacleCells.length} Zellen`)
|
||||
const [otx, oty] = obstacleCells[0].split(',').map(Number)
|
||||
eng.money = 999999
|
||||
eng.selectedObstacle = { tx: otx, ty: oty }
|
||||
eng.removeSelectedObstacle()
|
||||
check('Hindernis entfernbar', !eng.blockedCells.has(`${otx},${oty}`) && eng.canPlace(otx, oty))
|
||||
|
||||
// 4. can place a tower next to the path
|
||||
const candidates: [number, number][] = []
|
||||
for (let tx = 0; tx < 20 && candidates.length < 1; tx++) {
|
||||
for (let ty = 0; ty < 11; ty++) {
|
||||
if (eng.canPlace(tx, ty)) {
|
||||
candidates.push([tx, ty])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
check('Freier Bauplatz gefunden', candidates.length > 0)
|
||||
}
|
||||
|
||||
// 5. maps are distinct from each other
|
||||
console.log('\n=== Karten unterscheiden sich ===')
|
||||
const signatures = MAP_ORDER.map((m) => {
|
||||
const e = new GameEngine()
|
||||
e.setMap(m)
|
||||
return [m, [...e.pathCells].sort().join('|')] as const
|
||||
})
|
||||
for (let i = 0; i < signatures.length; i++) {
|
||||
for (let j = i + 1; j < signatures.length; j++) {
|
||||
check(
|
||||
`${signatures[i][0]} ≠ ${signatures[j][0]}`,
|
||||
signatures[i][1] !== signatures[j][1],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 6. lockstep determinism per map (same action stream on two engines)
|
||||
console.log('\n=== Lockstep-Determinismus je Karte ===')
|
||||
for (const mapId of MAP_ORDER) {
|
||||
const e1 = new GameEngine()
|
||||
const e2 = new GameEngine()
|
||||
for (const e of [e1, e2]) {
|
||||
e.startGame('normal', mapId)
|
||||
e.mpGameActive = true
|
||||
e.mpMode = 'coop'
|
||||
e.coopHpMul = 1.7
|
||||
}
|
||||
|
||||
const script: [number, Parameters<GameEngine['applyAction']>[0]][] = [
|
||||
[5, { type: 'build', kind: 'arrow', tx: 8, ty: 5 }],
|
||||
[10, { type: 'wave' }],
|
||||
[200, { type: 'build', kind: 'frost', tx: 4, ty: 0 }],
|
||||
[700, { type: 'wave' }],
|
||||
[900, { type: 'obstacle', tx: 19, ty: 0 }],
|
||||
[1500, { type: 'speed', s: 2 }],
|
||||
]
|
||||
|
||||
let idx = 0
|
||||
for (let tick = 1; tick <= 2400; tick++) {
|
||||
while (idx < script.length && script[idx][0] <= tick) {
|
||||
const a = script[idx][1]
|
||||
e1.applyAction(a, idx % 2)
|
||||
e2.applyAction(a, idx % 2)
|
||||
idx++
|
||||
}
|
||||
e1.update(1 / 30)
|
||||
e2.update(1 / 30)
|
||||
}
|
||||
|
||||
const state = (e: GameEngine): string =>
|
||||
JSON.stringify({
|
||||
money: Math.floor(e.money * 1000),
|
||||
lives: e.lives,
|
||||
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]),
|
||||
blocked: e.blockedCells.size,
|
||||
pathLen: e.pathPx.length,
|
||||
})
|
||||
check(`${mapId}: identischer Zustand`, state(e1) === state(e2))
|
||||
}
|
||||
|
||||
// 7. full campaign simulation on every map (playable check)
|
||||
console.log('\n=== 20-Wellen-Simulation je Karte ===')
|
||||
for (const mapId of MAP_ORDER) {
|
||||
const e = new GameEngine()
|
||||
e.startGame('easy', mapId)
|
||||
// generous money + a tower on every free tile to verify the map is beatable
|
||||
e.money = 999999
|
||||
for (let tx = 0; tx < 20; tx++) {
|
||||
for (let ty = 0; ty < 11; ty++) {
|
||||
if (e.canPlace(tx, ty)) e.placeTower('laser', tx, ty)
|
||||
}
|
||||
}
|
||||
let finished = false
|
||||
for (let t = 0; t < 60000 && !finished; t++) {
|
||||
e.update(1 / 30)
|
||||
if (e.phase === 'intermission') {
|
||||
e.startWave(true)
|
||||
}
|
||||
finished = e.phase === 'victory' || e.phase === 'gameover'
|
||||
}
|
||||
check(`${mapId}: Kampagne abgeschlossen`, e.phase === 'victory', `Phase: ${e.phase}, Welle ${e.waveNo}`)
|
||||
}
|
||||
|
||||
if (failures > 0) {
|
||||
console.error(`\n${failures} Prüfung(en) fehlgeschlagen.`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('\nAlle Karten-Prüfungen bestanden. ✅')
|
||||
Loading…
Add table
Add a link
Reference in a new issue