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:
Tronax 2026-08-16 15:03:34 +02:00
parent a7fb3efa61
commit ce2484bb8d
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
13 changed files with 911 additions and 132 deletions

View file

@ -2,7 +2,7 @@ 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'
import type { MapId, MPAction, MPMode, TargetingMode } from './types'
/** simulation tick rate in multiplayer (both clients advance identical ticks) */
const TICK_DT = 1 / 30
@ -73,9 +73,10 @@ class MpGameController {
}
}
private handleRoom(info: { code: string; mode: MPMode; players: { id: number; name: string }[]; you: number }): void {
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
@ -84,15 +85,16 @@ class MpGameController {
store.screen = 'lobby'
}
async create(mode: MPMode, name: string): Promise<void> {
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)
this.net.createRoom(mode, name, mapId)
} catch (err) {
store.mp.status = err instanceof Error ? err.message : 'Verbindung fehlgeschlagen.'
} finally {
@ -100,6 +102,13 @@ class MpGameController {
}
}
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
@ -139,7 +148,7 @@ class MpGameController {
// ---------------------------------------------------------------- game start
private handleStart(info: { you: number; mode: MPMode; players: { id: number; name: string }[] }): void {
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
@ -150,9 +159,10 @@ class MpGameController {
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.startGame('normal')
engine.startGame('normal', chosenMap)
engine.mpGameActive = true
engine.mpMode = 'coop'
engine.localPlayerId = info.you
@ -161,13 +171,13 @@ class MpGameController {
this.engines = [engine]
this.remoteEngine = null
} else {
engine.startGame('normal')
engine.startGame('normal', chosenMap)
engine.mpGameActive = true
engine.mpMode = 'duel'
engine.localPlayerId = info.you
engine.mpController = this
const remote = new GameEngine()
remote.startGame('normal')
remote.startGame('normal', chosenMap)
remote.mpGameActive = true
remote.mpMode = 'duel'
remote.localPlayerId = 1 - info.you
@ -182,6 +192,7 @@ class MpGameController {
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