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

70
scripts/test-map-room.mjs Normal file
View 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
View 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. ✅')

View file

@ -235,12 +235,18 @@ function makeCode() {
return code return code
} }
const VALID_MAPS = ['meadow', 'desert', 'frostland', 'volcano']
function sanitizeName(raw) { function sanitizeName(raw) {
if (typeof raw !== 'string') return 'Spieler' if (typeof raw !== 'string') return 'Spieler'
const clean = raw.trim().replace(/[^\p{L}\p{N}_\- ]/gu, '').slice(0, 12) const clean = raw.trim().replace(/[^\p{L}\p{N}_\- ]/gu, '').slice(0, 12)
return clean || 'Spieler' return clean || 'Spieler'
} }
function sanitizeMapId(raw) {
return VALID_MAPS.includes(raw) ? raw : 'meadow'
}
function send(ws, obj) { function send(ws, obj) {
if (ws && ws.readyState === 1) { if (ws && ws.readyState === 1) {
try { try {
@ -384,10 +390,21 @@ wss.on('connection', (ws) => {
return send(ws, { t: 'error', msg: 'Zu viele aktive Räume. Bitte später erneut versuchen.' }) return send(ws, { t: 'error', msg: 'Zu viele aktive Räume. Bitte später erneut versuchen.' })
} }
const code = makeCode() const code = makeCode()
const room = { code, mode: m.mode, created: Date.now(), seq: 0, players: [{ ws, id: 0, name: sanitizeName(m.name) }] } const mapId = sanitizeMapId(m.mapId)
const room = { code, mode: m.mode, mapId, created: Date.now(), seq: 0, players: [{ ws, id: 0, name: sanitizeName(m.name) }] }
rooms.set(code, room) rooms.set(code, room)
ws._room = code ws._room = code
send(ws, { t: 'room', code, mode: room.mode, players: playerInfo(room), you: 0 }) send(ws, { t: 'room', code, mode: room.mode, mapId: room.mapId, players: playerInfo(room), you: 0 })
break
}
case 'set-map': {
const room = roomOf(ws)
if (!room || room.players.length === 0) return
if (room.players[0].ws !== ws) return // only host may change map
room.mapId = sanitizeMapId(m.mapId)
for (const p of room.players) {
send(p.ws, { t: 'room', code: room.code, mode: room.mode, mapId: room.mapId, players: playerInfo(room), you: p.id })
}
break break
} }
case 'join': { case 'join': {
@ -399,7 +416,7 @@ wss.on('connection', (ws) => {
room.players.push({ ws, id: 1, name: sanitizeName(m.name) }) room.players.push({ ws, id: 1, name: sanitizeName(m.name) })
ws._room = code ws._room = code
for (const p of room.players) { for (const p of room.players) {
send(p.ws, { t: 'room', code, mode: room.mode, players: playerInfo(room), you: p.id }) send(p.ws, { t: 'room', code: room.code, mode: room.mode, mapId: room.mapId, players: playerInfo(room), you: p.id })
} }
break break
} }
@ -409,7 +426,7 @@ wss.on('connection', (ws) => {
if (room.players[0].ws !== ws) return // only the host may start if (room.players[0].ws !== ws) return // only the host may start
const seed = (Math.random() * 1e9) | 0 const seed = (Math.random() * 1e9) | 0
for (const p of room.players) { for (const p of room.players) {
send(p.ws, { t: 'start', seed, mode: room.mode, players: playerInfo(room), you: p.id }) send(p.ws, { t: 'start', seed, mode: room.mode, mapId: room.mapId, players: playerInfo(room), you: p.id })
} }
break break
} }

View file

@ -4,7 +4,7 @@ import { engine } from '@/game/engine'
import { mpgame } from '@/game/mpgame' import { mpgame } from '@/game/mpgame'
function restart(): void { function restart(): void {
engine.startGame(store.difficulty) engine.startGame(store.difficulty, store.mapId)
} }
function endless(): void { function endless(): void {

View file

@ -1,7 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { MAPS, MAP_ORDER } from '@/game/config'
import { store } from '@/game/store' import { store } from '@/game/store'
import { mpgame } from '@/game/mpgame' import { mpgame } from '@/game/mpgame'
import type { MapId } from '@/game/types'
const modeText = computed(() => const modeText = computed(() =>
store.mp.mode === 'coop' store.mp.mode === 'coop'
@ -16,6 +18,10 @@ function leave(): void {
function start(): void { function start(): void {
mpgame.requestStart() mpgame.requestStart()
} }
function pickMap(mId: MapId): void {
mpgame.setMap(mId)
}
</script> </script>
<template> <template>
@ -35,6 +41,25 @@ function start(): void {
<span class="hint">Diesen Code an deinen Mitspieler weitergeben</span> <span class="hint">Diesen Code an deinen Mitspieler weitergeben</span>
</div> </div>
<div class="map-block">
<span class="label">Karte</span>
<div class="maps-grid">
<button
v-for="mId in MAP_ORDER"
:key="mId"
class="map-card"
:class="{ active: store.mp.mapId === mId }"
:disabled="!store.mp.isHost"
:title="store.mp.isHost ? 'Karte ändern' : 'Der Host wählt die Karte'"
@click="pickMap(mId)"
>
<span class="map-icon">{{ MAPS[mId].icon }}</span>
<span class="map-name">{{ MAPS[mId].name }}</span>
</button>
</div>
<span v-if="!store.mp.isHost" class="hint">Der Host wählt die Karte</span>
</div>
<div class="players"> <div class="players">
<div <div
v-for="p in store.mp.players" v-for="p in store.mp.players"
@ -113,6 +138,46 @@ h2 {
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
} }
.map-block {
display: flex;
flex-direction: column;
gap: 8px;
}
.maps-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.map-card {
display: flex;
align-items: center;
gap: 8px;
background: var(--panel-inset);
border: 2px solid var(--panel-border);
border-radius: 10px;
padding: 8px 10px;
color: var(--text);
font-family: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: border-color 0.15s ease;
}
.map-card:disabled {
cursor: default;
opacity: 0.85;
}
.map-card.active {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(245, 197, 66, 0.2);
}
.map-icon {
font-size: 20px;
}
.map-name {
flex: 1;
text-align: left;
}
.label { .label {
font-size: 11px; font-size: 11px;
color: var(--text-dim); color: var(--text-dim);

View file

@ -1,29 +1,39 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { DIFFICULTIES } from '@/game/config' import { DIFFICULTIES, MAPS, MAP_ORDER } from '@/game/config'
import { engine } from '@/game/engine' import { engine } from '@/game/engine'
import { mpgame } from '@/game/mpgame' import { mpgame } from '@/game/mpgame'
import { loadBest, store } from '@/game/store' import { loadBest, store } from '@/game/store'
import type { DifficultyId, MPMode } from '@/game/types' import type { DifficultyId, MapId, MPMode } from '@/game/types'
const selected = ref<DifficultyId>( const selected = ref<DifficultyId>(
(typeof localStorage !== 'undefined' && (localStorage.getItem('trxtd-diff') as DifficultyId)) || 'normal', (typeof localStorage !== 'undefined' && (localStorage.getItem('trxtd-diff') as DifficultyId)) || 'normal',
) )
const selectedMap = ref<MapId>(
(typeof localStorage !== 'undefined' && (localStorage.getItem('trxtd-map') as MapId)) || 'meadow',
)
const mpName = ref((typeof localStorage !== 'undefined' && localStorage.getItem('trxtd-name')) || '') const mpName = ref((typeof localStorage !== 'undefined' && localStorage.getItem('trxtd-name')) || '')
const mpCode = ref('') const mpCode = ref('')
function start(): void { function start(): void {
if (typeof localStorage !== 'undefined') localStorage.setItem('trxtd-diff', selected.value) if (typeof localStorage !== 'undefined') {
localStorage.setItem('trxtd-diff', selected.value)
localStorage.setItem('trxtd-map', selectedMap.value)
}
engine.toggleMute() engine.toggleMute()
engine.toggleMute() engine.toggleMute()
engine.startGame(selected.value) engine.startGame(selected.value, selectedMap.value)
} }
async function createRoom(mode: MPMode): Promise<void> { async function createRoom(mode: MPMode): Promise<void> {
const name = mpName.value.trim() || 'Spieler' const name = mpName.value.trim() || 'Spieler'
if (typeof localStorage !== 'undefined') localStorage.setItem('trxtd-name', name) if (typeof localStorage !== 'undefined') {
await mpgame.create(mode, name) localStorage.setItem('trxtd-name', name)
localStorage.setItem('trxtd-map', selectedMap.value)
}
await mpgame.create(mode, name, selectedMap.value)
} }
async function joinRoom(): Promise<void> { async function joinRoom(): Promise<void> {
@ -48,6 +58,26 @@ async function joinRoom(): Promise<void> {
<p class="subtitle">Tower Defense im Browser verteidige deine Basis gegen 20 Wellen!</p> <p class="subtitle">Tower Defense im Browser verteidige deine Basis gegen 20 Wellen!</p>
</div> </div>
<div class="section-title">Karte auswählen</div>
<div class="maps-grid">
<button
v-for="mId in MAP_ORDER"
:key="mId"
class="map-card"
:class="{ active: selectedMap === mId }"
@click="selectedMap = mId"
>
<div class="map-icon">{{ MAPS[mId].icon }}</div>
<div class="map-info">
<div class="map-name">{{ MAPS[mId].name }}</div>
<div class="map-sub">{{ MAPS[mId].subtitle }}</div>
<div class="map-desc">{{ MAPS[mId].desc }}</div>
<div class="map-best">🏆 Bestleistung: {{ loadBest(selected, mId) || '—' }}</div>
</div>
</button>
</div>
<div class="section-title">Schwierigkeitsgrad</div>
<div class="diffs"> <div class="diffs">
<button <button
v-for="d in Object.values(DIFFICULTIES)" v-for="d in Object.values(DIFFICULTIES)"
@ -58,7 +88,6 @@ async function joinRoom(): Promise<void> {
> >
<div class="diff-name">{{ d.name }}</div> <div class="diff-name">{{ d.name }}</div>
<div class="diff-desc">{{ d.desc }}</div> <div class="diff-desc">{{ d.desc }}</div>
<div class="diff-best">🏆 Bestleistung: {{ loadBest(d.id) || '—' }}</div>
</button> </button>
</div> </div>
@ -123,12 +152,12 @@ async function joinRoom(): Promise<void> {
<style scoped> <style scoped>
.start { .start {
max-width: 760px; max-width: 860px;
margin: 0 auto; margin: 0 auto;
padding: 32px 16px 48px; padding: 32px 16px 48px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 22px; gap: 20px;
align-items: center; align-items: center;
} }
.hero { .hero {
@ -167,18 +196,86 @@ h1 {
margin: 6px 0 0; margin: 6px 0 0;
font-size: 15px; font-size: 15px;
} }
.section-title {
font-size: 14px;
font-weight: 800;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 1.5px;
margin-top: 4px;
}
.maps-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(195px, 1fr));
gap: 12px;
width: 100%;
}
.map-card {
background: var(--panel);
border: 2px solid var(--panel-border);
border-radius: 14px;
padding: 14px 14px;
text-align: left;
cursor: pointer;
color: var(--text);
font-family: inherit;
display: flex;
gap: 12px;
align-items: flex-start;
transition: border-color 0.15s ease, transform 0.1s ease, box-shadow 0.15s ease;
}
.map-card:hover {
transform: translateY(-2px);
}
.map-card.active {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(245, 197, 66, 0.25);
background: linear-gradient(180deg, rgba(245, 197, 66, 0.08), var(--panel));
}
.map-icon {
font-size: 28px;
line-height: 1;
}
.map-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.map-name {
font-weight: 800;
font-size: 15px;
}
.map-sub {
font-size: 11px;
color: var(--accent);
font-weight: 700;
}
.map-desc {
color: var(--text-dim);
font-size: 11.5px;
margin-top: 3px;
line-height: 1.35;
}
.map-best {
margin-top: 6px;
font-size: 11px;
color: #ffd23e;
}
.diffs { .diffs {
display: flex; display: flex;
gap: 10px; gap: 10px;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; justify-content: center;
width: 100%;
} }
.diff-card { .diff-card {
background: var(--panel); background: var(--panel);
border: 2px solid var(--panel-border); border: 2px solid var(--panel-border);
border-radius: 14px; border-radius: 14px;
padding: 14px 16px; padding: 14px 16px;
width: 200px; flex: 1;
min-width: 180px;
max-width: 260px;
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
color: var(--text); color: var(--text);
@ -202,11 +299,6 @@ h1 {
margin-top: 4px; margin-top: 4px;
min-height: 34px; min-height: 34px;
} }
.diff-best {
margin-top: 8px;
font-size: 12px;
color: #ffd23e;
}
.start-btn { .start-btn {
background: linear-gradient(180deg, #59b34d, #3f8f37); background: linear-gradient(180deg, #59b34d, #3f8f37);
border: none; border: none;
@ -291,16 +383,6 @@ h1 {
color: #ff8a7a; color: #ff8a7a;
font-size: 12.5px; font-size: 12.5px;
} }
.mp-hint {
margin: 0;
color: var(--text-dim);
font-size: 11.5px;
}
.mp-hint code {
background: var(--panel-inset);
border-radius: 4px;
padding: 1px 5px;
}
.help { .help {
display: flex; display: flex;
gap: 24px; gap: 24px;
@ -311,7 +393,7 @@ h1 {
line-height: 1.55; line-height: 1.55;
} }
.help-col { .help-col {
max-width: 330px; max-width: 360px;
background: var(--panel); background: var(--panel);
border: 1px solid var(--panel-border); border: 1px solid var(--panel-border);
border-radius: 12px; border-radius: 12px;

View file

@ -1,4 +1,4 @@
import type { DifficultyDef, DifficultyId, EnemyDef, EnemyKind, TowerDef, TowerKind, WaveGroup } from './types' import type { DifficultyDef, DifficultyId, EnemyDef, EnemyKind, MapDef, MapId, TowerDef, TowerKind, WaveGroup } from './types'
export const TILE = 48 export const TILE = 48
export const COLS = 20 export const COLS = 20
@ -22,8 +22,39 @@ export function playerColor(playerId: number | null): string {
return playerId === null || playerId === 0 ? PLAYER_COLORS[0] : PLAYER_COLORS[1] return playerId === null || playerId === 0 ? PLAYER_COLORS[0] : PLAYER_COLORS[1]
} }
/** ground path in tile coords; -1/20 are outside the grid (spawn/exit) */ export const MAPS: Record<MapId, MapDef> = {
export const MAP_WAYPOINTS: [number, number][] = [ meadow: {
id: 'meadow',
name: 'Grüne Lichtung',
subtitle: 'Klassisches Tal',
desc: 'Ausgewogener S-Kurven-Pfad durch saftige Wiesen mit vielen strategischen Bauplätzen.',
icon: '🌿',
theme: {
biome: 'grassland',
bgGradient: ['#3d6b39', '#48783f'],
checkerAlpha: 0.022,
gridColor: 'rgba(0,0,0,0.06)',
speckleDark: 'rgba(0,0,0,0.05)',
speckleLight: 'rgba(255,255,255,0.04)',
pathBorder: '#5f4a33',
pathEdge: '#7a6142',
pathMain: '#c2a276',
pathDash: 'rgba(216,195,154,0.55)',
pathPebbles: ['rgba(95,74,51,0.5)', 'rgba(230,210,175,0.5)'],
portalColor: '#2a1e3d',
portalRing: 'rgba(178,120,255,0.7)',
portalCore: 'rgba(200,160,255,0.9)',
baseColor: '#8d8577',
baseAccent: '#a49b8b',
treeFoliage: ['#2f6b33', '#3b7c3c'],
treeTrunk: '#6b4526',
rockColor: '#8b9298',
rockHighlight: 'rgba(255,255,255,0.22)',
bushColor: ['#3e7a3a', '#4f8f45'],
flowers: ['#e8657f', '#f5d55b', '#ffffff', '#c17ee0'],
obstacleTypes: { primary: 'tree', secondary: 'rock' },
},
waypoints: [
[-1, 5], [-1, 5],
[2, 5], [2, 5],
[2, 1], [2, 1],
@ -36,13 +67,205 @@ export const MAP_WAYPOINTS: [number, number][] = [
[17, 9], [17, 9],
[17, 5], [17, 5],
[20, 5], [20, 5],
] ],
flyWaypoints: [
/** flying enemies fly straight across the middle */
export const FLY_WAYPOINTS: [number, number][] = [
[-1, 5], [-1, 5],
[20, 5], [20, 5],
] ],
decorCounts: {
primaryObstacle: 7,
secondaryObstacle: 5,
bush: 10,
detail: 22,
},
decorDistLimits: {
primaryDist: [2.1, 99],
secondaryDist: [1.6, 99],
},
},
desert: {
id: 'desert',
name: 'Sonnendünen',
subtitle: 'Glühende Wüste',
desc: 'Langer, gewundener Sanddünenpfad mit Palmen, Felsblöcken und Kakteen.',
icon: '🏜️',
theme: {
biome: 'desert',
bgGradient: ['#c99a4e', '#d8a95d'],
checkerAlpha: 0.035,
gridColor: 'rgba(110,65,15,0.08)',
speckleDark: 'rgba(90,50,10,0.08)',
speckleLight: 'rgba(255,240,190,0.08)',
pathBorder: '#7d4a1b',
pathEdge: '#9a602a',
pathMain: '#e6c88b',
pathDash: 'rgba(255,245,210,0.6)',
pathPebbles: ['rgba(125,74,27,0.5)', 'rgba(255,230,170,0.5)'],
portalColor: '#3a2010',
portalRing: 'rgba(255,170,60,0.8)',
portalCore: 'rgba(255,220,130,0.95)',
baseColor: '#b58a5c',
baseAccent: '#d4aa7c',
treeFoliage: ['#3e803a', '#5ba040'], // Palm fronds
treeTrunk: '#8a623a',
rockColor: '#ba7a4e', // Sandstone rocks
rockHighlight: 'rgba(255,235,190,0.35)',
bushColor: ['#7a8f35', '#9ab540'], // Desert scrub
flowers: ['#ff8a43', '#ffc83b', '#e05a47', '#ffdf80'],
obstacleTypes: { primary: 'tree', secondary: 'rock' },
},
waypoints: [
[-1, 2],
[4, 2],
[4, 8],
[8, 8],
[8, 2],
[12, 2],
[12, 8],
[16, 8],
[16, 3],
[20, 3],
],
flyWaypoints: [
[-1, 2],
[20, 3],
],
decorCounts: {
primaryObstacle: 6,
secondaryObstacle: 6,
bush: 8,
detail: 16,
},
decorDistLimits: {
primaryDist: [2.0, 99],
secondaryDist: [1.5, 99],
},
},
frostland: {
id: 'frostland',
name: 'Frostgipfel',
subtitle: 'Eisige Einöde',
desc: 'Ein eisiger Serpentinenweg durch Schnee und gefrorene Tannenwälder.',
icon: '❄️',
theme: {
biome: 'snow',
bgGradient: ['#7d9bb8', '#90abc7'],
checkerAlpha: 0.03,
gridColor: 'rgba(40,70,110,0.08)',
speckleDark: 'rgba(30,60,95,0.07)',
speckleLight: 'rgba(255,255,255,0.18)',
pathBorder: '#3b556b',
pathEdge: '#526f87',
pathMain: '#c8dceb',
pathDash: 'rgba(235,245,255,0.7)',
pathPebbles: ['rgba(60,90,115,0.5)', 'rgba(240,250,255,0.7)'],
portalColor: '#122538',
portalRing: 'rgba(120,210,255,0.85)',
portalCore: 'rgba(200,240,255,0.95)',
baseColor: '#607991',
baseAccent: '#819db8',
treeFoliage: ['#1e4a4d', '#2c6663'], // Pine trees covered in snow
treeTrunk: '#423b38',
rockColor: '#5c7891', // Frosty ice crystals/rocks
rockHighlight: 'rgba(215,245,255,0.45)',
bushColor: ['#3a6369', '#507f82'],
flowers: ['#bfe8ff', '#ffffff', '#90d4ff', '#e0f4ff'],
obstacleTypes: { primary: 'tree', secondary: 'rock' },
},
waypoints: [
[-1, 8],
[3, 8],
[3, 2],
[7, 2],
[7, 9],
[13, 9],
[13, 3],
[17, 3],
[17, 7],
[20, 7],
],
flyWaypoints: [
[-1, 8],
[20, 7],
],
decorCounts: {
primaryObstacle: 8,
secondaryObstacle: 5,
bush: 9,
detail: 20,
},
decorDistLimits: {
primaryDist: [2.0, 99],
secondaryDist: [1.6, 99],
},
},
volcano: {
id: 'volcano',
name: 'Lavabruch',
subtitle: 'Feurige Schlucht',
desc: 'Aggressiver, enger Pfad durch erkaltetes Lavagestein mit glühenden Magmaadern.',
icon: '🌋',
theme: {
biome: 'magma',
bgGradient: ['#282024', '#332629'],
checkerAlpha: 0.04,
gridColor: 'rgba(255,80,20,0.06)',
speckleDark: 'rgba(0,0,0,0.12)',
speckleLight: 'rgba(255,110,40,0.08)',
pathBorder: '#521f18',
pathEdge: '#782d20',
pathMain: '#e86a38',
pathDash: 'rgba(255,200,100,0.65)',
pathPebbles: ['rgba(80,25,20,0.6)', 'rgba(255,160,60,0.6)'],
portalColor: '#2b0d09',
portalRing: 'rgba(255,80,30,0.9)',
portalCore: 'rgba(255,200,80,0.95)',
baseColor: '#4d3b3b',
baseAccent: '#6b4f4f',
treeFoliage: ['#382b28', '#473430'], // Charred dead trees
treeTrunk: '#1a1414',
rockColor: '#3d3434', // Basalt obsidian rocks
rockHighlight: 'rgba(255,130,50,0.4)',
bushColor: ['#472c26', '#5e3730'],
flowers: ['#ff4d29', '#ff9436', '#ffd24d', '#ff2a2a'], // Glowing embers
obstacleTypes: { primary: 'tree', secondary: 'rock' },
},
waypoints: [
[-1, 1],
[4, 1],
[4, 6],
[1, 6],
[1, 9],
[8, 9],
[8, 4],
[14, 4],
[14, 8],
[18, 8],
[18, 2],
[20, 2],
],
flyWaypoints: [
[-1, 1],
[20, 2],
],
decorCounts: {
primaryObstacle: 7,
secondaryObstacle: 6,
bush: 8,
detail: 24,
},
decorDistLimits: {
primaryDist: [1.9, 99],
secondaryDist: [1.5, 99],
},
},
}
export const MAP_ORDER: MapId[] = ['meadow', 'desert', 'frostland', 'volcano']
/** Default fallback waypoints for backward compatibility */
export const MAP_WAYPOINTS: [number, number][] = MAPS.meadow.waypoints
export const FLY_WAYPOINTS: [number, number][] = MAPS.meadow.flyWaypoints
export const TOWER_ORDER: TowerKind[] = ['arrow', 'cannon', 'frost', 'tesla', 'laser'] export const TOWER_ORDER: TowerKind[] = ['arrow', 'cannon', 'frost', 'tesla', 'laser']

View file

@ -1,9 +1,9 @@
import { import {
DIFFICULTIES, DIFFICULTIES,
ENEMIES, ENEMIES,
FLY_WAYPOINTS,
INTERMISSION, INTERMISSION,
MAP_WAYPOINTS, MAPS,
MAP_ORDER,
OBSTACLE_COST, OBSTACLE_COST,
SELL_RATIO, SELL_RATIO,
TILE, TILE,
@ -26,6 +26,8 @@ import type {
EnemyKind, EnemyKind,
MPAction, MPAction,
MPMode, MPMode,
MapDef,
MapId,
Phase, Phase,
Projectile, Projectile,
Pt, Pt,
@ -84,6 +86,11 @@ export class GameEngine {
speed = 1 speed = 1
endless = false endless = false
difficulty: DifficultyId = 'normal' difficulty: DifficultyId = 'normal'
mapId: MapId = 'meadow'
get mapDef(): MapDef {
return MAPS[this.mapId] || MAPS.meadow
}
buildType: TowerKind | null = null buildType: TowerKind | null = null
hover: { x: number; y: number; tx: number; ty: number; valid: boolean } | null = null hover: { x: number; y: number; tx: number; ty: number; valid: boolean } | null = null
@ -120,14 +127,23 @@ export class GameEngine {
// ---------------------------------------------------------------- map // ---------------------------------------------------------------- map
private buildMap(): void { setMap(mapId: MapId): void {
const toPx = (p: [number, number]): Pt => ({ x: p[0] * TILE + TILE / 2, y: p[1] * TILE + TILE / 2 }) if (MAPS[mapId]) {
this.pathPx = MAP_WAYPOINTS.map(toPx) this.mapId = mapId
this.flyPathPx = FLY_WAYPOINTS.map(toPx) this.buildMap()
}
}
for (let i = 0; i < MAP_WAYPOINTS.length - 1; i++) { private buildMap(): void {
const [ax, ay] = MAP_WAYPOINTS[i] const map = this.mapDef
const [bx, by] = MAP_WAYPOINTS[i + 1] const toPx = (p: [number, number]): Pt => ({ x: p[0] * TILE + TILE / 2, y: p[1] * TILE + TILE / 2 })
this.pathPx = map.waypoints.map(toPx)
this.flyPathPx = map.flyWaypoints.map(toPx)
this.pathCells = new Set()
for (let i = 0; i < map.waypoints.length - 1; i++) {
const [ax, ay] = map.waypoints[i]
const [bx, by] = map.waypoints[i + 1]
const dx = Math.sign(bx - ax) const dx = Math.sign(bx - ax)
const dy = Math.sign(by - ay) const dy = Math.sign(by - ay)
let x = ax let x = ax
@ -140,10 +156,11 @@ export class GameEngine {
} }
} }
// deterministic decoration // deterministic decoration based on map id
this.decor = [] this.decor = []
this.blockedCells = new Set() this.blockedCells = new Set()
const rng = mulberry32(20260815) const mapSeed = map.id.split('').reduce((acc, c) => acc * 31 + c.charCodeAt(0), 20260815)
const rng = mulberry32(mapSeed)
const cellDistToPath = (tx: number, ty: number): number => { const cellDistToPath = (tx: number, ty: number): number => {
let best = Infinity let best = Infinity
for (const key of this.pathCells) { for (const key of this.pathCells) {
@ -170,10 +187,11 @@ export class GameEngine {
placed++ placed++
} }
} }
tryDecor('tree', 7, 2.1, 99) const limits = map.decorDistLimits || { primaryDist: [2.1, 99], secondaryDist: [1.6, 99] }
tryDecor('rock', 5, 1.6, 99) tryDecor('tree', map.decorCounts.primaryObstacle, limits.primaryDist[0], limits.primaryDist[1])
tryDecor('bush', 10, 1.05, 99) tryDecor('rock', map.decorCounts.secondaryObstacle, limits.secondaryDist[0], limits.secondaryDist[1])
tryDecor('flower', 22, 0, 99) tryDecor('bush', map.decorCounts.bush, 1.05, 99)
tryDecor('flower', map.decorCounts.detail, 0, 99)
} }
private markPathCell(x: number, y: number): void { private markPathCell(x: number, y: number): void {
@ -236,10 +254,11 @@ export class GameEngine {
// ---------------------------------------------------------------- lifecycle // ---------------------------------------------------------------- lifecycle
startGame(diff: DifficultyId): void { startGame(diff: DifficultyId, mapId: MapId = 'meadow'): void {
const d = DIFFICULTIES[diff] const d = DIFFICULTIES[diff]
this.difficulty = diff this.difficulty = diff
this.buildMap() // restores removed obstacles this.mapId = mapId
this.buildMap() // restores removed obstacles & loads chosen map layout
this.enemies = [] this.enemies = []
this.towers = [] this.towers = []
this.projectiles = [] this.projectiles = []
@ -268,6 +287,7 @@ export class GameEngine {
store.speed = 1 store.speed = 1
store.muted = sound.muted store.muted = sound.muted
store.difficulty = diff store.difficulty = diff
store.mapId = mapId
store.money = this.money store.money = this.money
store.lives = this.lives store.lives = this.lives
store.maxLives = this.lives store.maxLives = this.lives
@ -509,10 +529,10 @@ export class GameEngine {
private finish(win: boolean): void { private finish(win: boolean): void {
if (this.mpGameActive) return // multiplayer results are handled by the controller if (this.mpGameActive) return // multiplayer results are handled by the controller
const bestBefore = loadBest(this.difficulty) const bestBefore = loadBest(this.difficulty, this.mapId)
const best = Math.max(bestBefore, this.score) const best = Math.max(bestBefore, this.score)
if (typeof localStorage !== 'undefined') { if (typeof localStorage !== 'undefined') {
localStorage.setItem(bestScoreKey(this.difficulty), String(best)) localStorage.setItem(bestScoreKey(this.difficulty, this.mapId), String(best))
} }
store.result = { win, score: this.score, wave: this.waveNo, kills: this.kills, best, bestBefore } store.result = { win, score: this.score, wave: this.waveNo, kills: this.kills, best, bestBefore }
store.screen = win ? 'victory' : 'gameover' store.screen = win ? 'victory' : 'gameover'

View file

@ -2,7 +2,7 @@ import { GameEngine, engine } from './engine'
import { MPClient, serverUrl } from './net' import { MPClient, serverUrl } from './net'
import { sound } from './sound' import { sound } from './sound'
import { store } from './store' 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) */ /** simulation tick rate in multiplayer (both clients advance identical ticks) */
const TICK_DT = 1 / 30 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.roomCode = info.code
store.mp.mode = info.mode store.mp.mode = info.mode
store.mp.mapId = info.mapId || 'meadow'
store.mp.players = info.players store.mp.players = info.players
store.mp.myId = info.you store.mp.myId = info.you
store.mp.isHost = info.you === 0 store.mp.isHost = info.you === 0
@ -84,15 +85,16 @@ class MpGameController {
store.screen = 'lobby' 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 if (this.connecting) return
this.connecting = true this.connecting = true
store.mp.status = 'Verbinde mit Server…' store.mp.status = 'Verbinde mit Server…'
try { try {
await this.net.connect(serverUrl()) await this.net.connect(serverUrl())
store.mp.name = name store.mp.name = name
store.mp.mapId = mapId
store.mp.active = false store.mp.active = false
this.net.createRoom(mode, name) this.net.createRoom(mode, name, mapId)
} catch (err) { } catch (err) {
store.mp.status = err instanceof Error ? err.message : 'Verbindung fehlgeschlagen.' store.mp.status = err instanceof Error ? err.message : 'Verbindung fehlgeschlagen.'
} finally { } 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> { async join(code: string, name: string): Promise<void> {
if (this.connecting) return if (this.connecting) return
this.connecting = true this.connecting = true
@ -139,7 +148,7 @@ class MpGameController {
// ---------------------------------------------------------------- game start // ---------------------------------------------------------------- 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.active = true
this.ended = false this.ended = false
this.mode = info.mode this.mode = info.mode
@ -150,9 +159,10 @@ class MpGameController {
const me = info.players.find((p) => p.id === info.you) const me = info.players.find((p) => p.id === info.you)
const peer = 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') { if (info.mode === 'coop') {
engine.startGame('normal') engine.startGame('normal', chosenMap)
engine.mpGameActive = true engine.mpGameActive = true
engine.mpMode = 'coop' engine.mpMode = 'coop'
engine.localPlayerId = info.you engine.localPlayerId = info.you
@ -161,13 +171,13 @@ class MpGameController {
this.engines = [engine] this.engines = [engine]
this.remoteEngine = null this.remoteEngine = null
} else { } else {
engine.startGame('normal') engine.startGame('normal', chosenMap)
engine.mpGameActive = true engine.mpGameActive = true
engine.mpMode = 'duel' engine.mpMode = 'duel'
engine.localPlayerId = info.you engine.localPlayerId = info.you
engine.mpController = this engine.mpController = this
const remote = new GameEngine() const remote = new GameEngine()
remote.startGame('normal') remote.startGame('normal', chosenMap)
remote.mpGameActive = true remote.mpGameActive = true
remote.mpMode = 'duel' remote.mpMode = 'duel'
remote.localPlayerId = 1 - info.you remote.localPlayerId = 1 - info.you
@ -182,6 +192,7 @@ class MpGameController {
store.mp.active = true store.mp.active = true
store.mp.mode = info.mode store.mp.mode = info.mode
store.mp.mapId = chosenMap
store.mp.name = me?.name ?? 'Du' store.mp.name = me?.name ?? 'Du'
store.mp.peerName = peer?.name ?? 'Gegner' store.mp.peerName = peer?.name ?? 'Gegner'
store.mp.myId = info.you store.mp.myId = info.you

View file

@ -1,8 +1,9 @@
import type { MPAction, MPMode } from './types' import type { MapId, MPAction, MPMode } from './types'
export interface RoomInfo { export interface RoomInfo {
code: string code: string
mode: MPMode mode: MPMode
mapId: MapId
players: { id: number; name: string }[] players: { id: number; name: string }[]
you: number you: number
} }
@ -11,6 +12,7 @@ export interface StartInfo {
you: number you: number
seed: number seed: number
mode: MPMode mode: MPMode
mapId: MapId
players: { id: number; name: string }[] players: { id: number; name: string }[]
} }
@ -122,8 +124,12 @@ export class MPClient {
if (this.connected && this.ws) this.ws.send(JSON.stringify(obj)) if (this.connected && this.ws) this.ws.send(JSON.stringify(obj))
} }
createRoom(mode: MPMode, name: string): void { createRoom(mode: MPMode, name: string, mapId: MapId = 'meadow'): void {
this.send({ t: 'create', mode, name }) this.send({ t: 'create', mode, name, mapId })
}
setMap(mapId: MapId): void {
this.send({ t: 'set-map', mapId })
} }
joinRoom(code: string, name: string): void { joinRoom(code: string, name: string): void {

View file

@ -1,6 +1,6 @@
import { ENEMIES, H, TILE, TOWERS, W, playerColor, towerTier, TIER_COLORS } from './config' import { ENEMIES, H, MAPS, TILE, TOWERS, W, playerColor, towerTier, TIER_COLORS } from './config'
import type { GameEngine } from './engine' import type { GameEngine } from './engine'
import type { Enemy, Tower } from './types' import type { Enemy, MapTheme, Tower } from './types'
import { mulberry32 } from './utils' import { mulberry32 } from './utils'
/** /**
@ -17,6 +17,8 @@ export class Renderer {
private pxScale = 1 private pxScale = 1
private cssW = W private cssW = W
private cssH = H private cssH = H
private currentMapId = ''
private currentTheme: MapTheme = MAPS.meadow.theme
constructor(canvas: HTMLCanvasElement) { constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas this.canvas = canvas
@ -41,7 +43,7 @@ export class Renderer {
this.pxScale = Math.min(3, (this.dpr * cssW) / W) this.pxScale = Math.min(3, (this.dpr * cssW) / W)
this.canvas.width = Math.round(W * this.pxScale) this.canvas.width = Math.round(W * this.pxScale)
this.canvas.height = Math.round(H * this.pxScale) this.canvas.height = Math.round(H * this.pxScale)
this.buildBackground() this.buildBackground(this.currentTheme)
} }
destroy(): void { destroy(): void {
@ -52,17 +54,20 @@ export class Renderer {
// ------------------------------------------------------------ background // ------------------------------------------------------------ background
private buildBackground(): void { buildBackground(theme?: MapTheme): void {
const c = this.bg const c = this.bg
c.width = this.canvas.width c.width = this.canvas.width
c.height = this.canvas.height c.height = this.canvas.height
const g = c.getContext('2d')! const g = c.getContext('2d')!
g.setTransform(this.pxScale, 0, 0, this.pxScale, 0, 0) g.setTransform(this.pxScale, 0, 0, this.pxScale, 0, 0)
// grass gradient const th = theme || MAPS.meadow.theme
this.currentTheme = th
// biome background gradient
const grad = g.createLinearGradient(0, 0, 0, H) const grad = g.createLinearGradient(0, 0, 0, H)
grad.addColorStop(0, '#3d6b39') grad.addColorStop(0, th.bgGradient[0])
grad.addColorStop(1, '#48783f') grad.addColorStop(1, th.bgGradient[1])
g.fillStyle = grad g.fillStyle = grad
g.fillRect(0, 0, W, H) g.fillRect(0, 0, W, H)
@ -70,23 +75,23 @@ export class Renderer {
for (let ty = 0; ty < 11; ty++) { for (let ty = 0; ty < 11; ty++) {
for (let tx = 0; tx < 20; tx++) { for (let tx = 0; tx < 20; tx++) {
if ((tx + ty) % 2 === 0) { if ((tx + ty) % 2 === 0) {
g.fillStyle = 'rgba(255,255,255,0.022)' g.fillStyle = `rgba(255,255,255,${th.checkerAlpha})`
g.fillRect(tx * TILE, ty * TILE, TILE, TILE) g.fillRect(tx * TILE, ty * TILE, TILE, TILE)
} }
} }
} }
// grass speckles // biome texture speckles
const rng = mulberry32(1337) const rng = mulberry32(1337)
for (let i = 0; i < 500; i++) { for (let i = 0; i < 500; i++) {
const x = rng() * W const x = rng() * W
const y = rng() * H const y = rng() * H
g.fillStyle = rng() < 0.5 ? 'rgba(0,0,0,0.05)' : 'rgba(255,255,255,0.04)' g.fillStyle = rng() < 0.5 ? th.speckleDark : th.speckleLight
g.fillRect(x, y, 2, 2) g.fillRect(x, y, 2, 2)
} }
// grid lines // grid lines
g.strokeStyle = 'rgba(0,0,0,0.06)' g.strokeStyle = th.gridColor
g.lineWidth = 1 g.lineWidth = 1
for (let x = 0; x <= 20; x++) { for (let x = 0; x <= 20; x++) {
g.beginPath() g.beginPath()
@ -104,25 +109,26 @@ export class Renderer {
private drawPath(g: CanvasRenderingContext2D, eng: GameEngine): void { private drawPath(g: CanvasRenderingContext2D, eng: GameEngine): void {
const pts = eng.pathPx const pts = eng.pathPx
const th = eng.mapDef.theme
g.lineJoin = 'round' g.lineJoin = 'round'
g.lineCap = 'round' g.lineCap = 'round'
g.beginPath() g.beginPath()
g.moveTo(pts[0].x, pts[0].y) g.moveTo(pts[0].x, pts[0].y)
for (let i = 1; i < pts.length; i++) g.lineTo(pts[i].x, pts[i].y) for (let i = 1; i < pts.length; i++) g.lineTo(pts[i].x, pts[i].y)
g.strokeStyle = '#5f4a33' g.strokeStyle = th.pathBorder
g.lineWidth = TILE * 0.78 g.lineWidth = TILE * 0.78
g.stroke() g.stroke()
g.strokeStyle = '#7a6142' g.strokeStyle = th.pathEdge
g.lineWidth = TILE * 0.7 g.lineWidth = TILE * 0.7
g.stroke() g.stroke()
g.strokeStyle = '#c2a276' g.strokeStyle = th.pathMain
g.lineWidth = TILE * 0.58 g.lineWidth = TILE * 0.58
g.stroke() g.stroke()
// dashed center line // dashed center line
g.save() g.save()
g.strokeStyle = 'rgba(216,195,154,0.55)' g.strokeStyle = th.pathDash
g.lineWidth = 3 g.lineWidth = 3
g.setLineDash([12, 16]) g.setLineDash([12, 16])
g.beginPath() g.beginPath()
@ -144,7 +150,7 @@ export class Renderer {
const len = Math.hypot(dx, dy) || 1 const len = Math.hypot(dx, dy) || 1
const px = a.x + dx * t + (-dy / len) * off const px = a.x + dx * t + (-dy / len) * off
const py = a.y + dy * t + (dx / len) * off const py = a.y + dy * t + (dx / len) * off
g.fillStyle = rng() < 0.5 ? 'rgba(95,74,51,0.5)' : 'rgba(230,210,175,0.5)' g.fillStyle = rng() < 0.5 ? th.pathPebbles[0] : th.pathPebbles[1]
g.beginPath() g.beginPath()
g.arc(px, py, 1.2 + rng() * 1.6, 0, Math.PI * 2) g.arc(px, py, 1.2 + rng() * 1.6, 0, Math.PI * 2)
g.fill() g.fill()
@ -152,12 +158,22 @@ export class Renderer {
} }
private drawDecor(g: CanvasRenderingContext2D, eng: GameEngine): void { private drawDecor(g: CanvasRenderingContext2D, eng: GameEngine): void {
const th = eng.mapDef.theme
for (const d of eng.decor) { for (const d of eng.decor) {
const { x, y, s } = d const { x, y, s } = d
if (d.type === 'flower') { if (d.type === 'flower') {
const cols = ['#e8657f', '#f5d55b', '#ffffff', '#c17ee0'] const col = th.flowers[Math.floor(d.seed * th.flowers.length)]
const col = cols[Math.floor(d.seed * cols.length)]
g.fillStyle = col g.fillStyle = col
if (th.biome === 'magma') {
// glowing ember spark
g.beginPath()
g.arc(x, y, 2.5 * s, 0, Math.PI * 2)
g.fill()
g.fillStyle = 'rgba(255,255,200,0.8)'
g.beginPath()
g.arc(x, y, 1.2 * s, 0, Math.PI * 2)
g.fill()
} else {
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
const fx = x + (i - 1) * 6 * s + (d.seed * 10 - 5) const fx = x + (i - 1) * 6 * s + (d.seed * 10 - 5)
const fy = y + ((i * 13) % 7 - 3) * s const fy = y + ((i * 13) % 7 - 3) * s
@ -165,16 +181,17 @@ export class Renderer {
g.arc(fx, fy, 2.2 * s, 0, Math.PI * 2) g.arc(fx, fy, 2.2 * s, 0, Math.PI * 2)
g.fill() g.fill()
} }
}
} else if (d.type === 'bush') { } else if (d.type === 'bush') {
g.fillStyle = 'rgba(0,0,0,0.15)' g.fillStyle = 'rgba(0,0,0,0.15)'
g.beginPath() g.beginPath()
g.ellipse(x, y + 8 * s, 11 * s, 4 * s, 0, 0, Math.PI * 2) g.ellipse(x, y + 8 * s, 11 * s, 4 * s, 0, 0, Math.PI * 2)
g.fill() g.fill()
g.fillStyle = '#3e7a3a' g.fillStyle = th.bushColor[0]
g.beginPath() g.beginPath()
g.ellipse(x, y, 10 * s, 7 * s, 0, 0, Math.PI * 2) g.ellipse(x, y, 10 * s, 7 * s, 0, 0, Math.PI * 2)
g.fill() g.fill()
g.fillStyle = '#4f8f45' g.fillStyle = th.bushColor[1]
g.beginPath() g.beginPath()
g.ellipse(x - 3 * s, y - 2 * s, 6 * s, 4.5 * s, 0, 0, Math.PI * 2) g.ellipse(x - 3 * s, y - 2 * s, 6 * s, 4.5 * s, 0, 0, Math.PI * 2)
g.fill() g.fill()
@ -183,7 +200,7 @@ export class Renderer {
g.beginPath() g.beginPath()
g.ellipse(x, y + 10 * s, 13 * s, 4 * s, 0, 0, Math.PI * 2) g.ellipse(x, y + 10 * s, 13 * s, 4 * s, 0, 0, Math.PI * 2)
g.fill() g.fill()
g.fillStyle = '#8b9298' g.fillStyle = th.rockColor
g.beginPath() g.beginPath()
g.moveTo(x - 12 * s, y + 8 * s) g.moveTo(x - 12 * s, y + 8 * s)
g.lineTo(x - 9 * s, y - 7 * s) g.lineTo(x - 9 * s, y - 7 * s)
@ -192,7 +209,7 @@ export class Renderer {
g.lineTo(x + 10 * s, y + 8 * s) g.lineTo(x + 10 * s, y + 8 * s)
g.closePath() g.closePath()
g.fill() g.fill()
g.fillStyle = 'rgba(255,255,255,0.22)' g.fillStyle = th.rockHighlight
g.beginPath() g.beginPath()
g.moveTo(x - 8 * s, y - 5 * s) g.moveTo(x - 8 * s, y - 5 * s)
g.lineTo(x + 1 * s, y - 9 * s) g.lineTo(x + 1 * s, y - 9 * s)
@ -200,19 +217,54 @@ export class Renderer {
g.closePath() g.closePath()
g.fill() g.fill()
} else { } else {
// tree // tree / obstacle
g.fillStyle = 'rgba(0,0,0,0.22)' g.fillStyle = 'rgba(0,0,0,0.22)'
g.beginPath() g.beginPath()
g.ellipse(x, y + 12 * s, 15 * s, 5 * s, 0, 0, Math.PI * 2) g.ellipse(x, y + 12 * s, 15 * s, 5 * s, 0, 0, Math.PI * 2)
g.fill() g.fill()
g.fillStyle = '#6b4526' g.fillStyle = th.treeTrunk
g.fillRect(x - 3.5 * s, y - 4 * s, 7 * s, 16 * s) g.fillRect(x - 3.5 * s, y - 4 * s, 7 * s, 16 * s)
const rng = mulberry32(Math.floor(d.seed * 1e9)) const rng = mulberry32(Math.floor(d.seed * 1e9))
if (th.biome === 'desert') {
// Palm fronds
for (let i = 0; i < 5; i++) {
const a = (i / 5) * Math.PI * 2
g.strokeStyle = i % 2 === 0 ? th.treeFoliage[0] : th.treeFoliage[1]
g.lineWidth = 4 * s
g.beginPath()
g.moveTo(x, y - 6 * s)
g.quadraticCurveTo(x + Math.cos(a) * 14 * s, y - 12 * s + Math.sin(a) * 8 * s, x + Math.cos(a) * 18 * s, y - 2 * s + Math.sin(a) * 10 * s)
g.stroke()
}
} else if (th.biome === 'snow') {
// Pine tree (layered triangles)
for (let i = 0; i < 3; i++) {
const py = y - 4 * s - i * 7 * s
const pw = (12 - i * 2.5) * s
g.fillStyle = i % 2 === 0 ? th.treeFoliage[0] : th.treeFoliage[1]
g.beginPath()
g.moveTo(x - pw, py)
g.lineTo(x, py - 9 * s)
g.lineTo(x + pw, py)
g.closePath()
g.fill()
// Snow cap
g.fillStyle = 'rgba(255,255,255,0.7)'
g.beginPath()
g.moveTo(x - pw * 0.4, py - 4 * s)
g.lineTo(x, py - 9 * s)
g.lineTo(x + pw * 0.4, py - 4 * s)
g.closePath()
g.fill()
}
} else {
// Standard / Deciduous tree
for (let i = 0; i < 4; i++) { for (let i = 0; i < 4; i++) {
const cx = x + (rng() - 0.5) * 16 * s const cx = x + (rng() - 0.5) * 16 * s
const cy = y - 8 * s - rng() * 12 * s const cy = y - 8 * s - rng() * 12 * s
const r = (8 + rng() * 5) * s const r = (8 + rng() * 5) * s
g.fillStyle = i % 2 === 0 ? '#2f6b33' : '#3b7c3c' g.fillStyle = i % 2 === 0 ? th.treeFoliage[0] : th.treeFoliage[1]
g.beginPath() g.beginPath()
g.arc(cx, cy, r, 0, Math.PI * 2) g.arc(cx, cy, r, 0, Math.PI * 2)
g.fill() g.fill()
@ -224,10 +276,16 @@ export class Renderer {
} }
} }
} }
}
// ------------------------------------------------------------ main render // ------------------------------------------------------------ main render
render(eng: GameEngine): void { render(eng: GameEngine): void {
if (this.currentMapId !== eng.mapId) {
this.currentMapId = eng.mapId
this.buildBackground(eng.mapDef.theme)
}
const g = this.ctx const g = this.ctx
g.setTransform(this.pxScale, 0, 0, this.pxScale, 0, 0) g.setTransform(this.pxScale, 0, 0, this.pxScale, 0, 0)
g.clearRect(0, 0, W, H) g.clearRect(0, 0, W, H)
@ -294,21 +352,22 @@ export class Renderer {
private drawSpawn(g: CanvasRenderingContext2D, eng: GameEngine): void { private drawSpawn(g: CanvasRenderingContext2D, eng: GameEngine): void {
const p = eng.pathPx[0] const p = eng.pathPx[0]
const th = eng.mapDef.theme
g.save() g.save()
g.translate(p.x + 18, p.y) g.translate(p.x + 18, p.y)
g.fillStyle = '#2a1e3d' g.fillStyle = th.portalColor
g.beginPath() g.beginPath()
g.ellipse(0, 0, 26, 30, 0, 0, Math.PI * 2) g.ellipse(0, 0, 26, 30, 0, 0, Math.PI * 2)
g.fill() g.fill()
const t = eng.time * 2 const t = eng.time * 2
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
g.strokeStyle = `rgba(178,120,255,${0.7 - i * 0.2})` g.strokeStyle = th.portalRing
g.lineWidth = 3 g.lineWidth = 3
g.beginPath() g.beginPath()
g.arc(0, 0, 8 + i * 7 + Math.sin(t + i * 2) * 3, t * (i % 2 === 0 ? 1 : -1), t * (i % 2 === 0 ? 1 : -1) + 4.2) g.arc(0, 0, 8 + i * 7 + Math.sin(t + i * 2) * 3, t * (i % 2 === 0 ? 1 : -1), t * (i % 2 === 0 ? 1 : -1) + 4.2)
g.stroke() g.stroke()
} }
g.fillStyle = 'rgba(200,160,255,0.9)' g.fillStyle = th.portalCore
g.beginPath() g.beginPath()
g.arc(0, 0, 3.5 + Math.sin(t * 3) * 1.2, 0, Math.PI * 2) g.arc(0, 0, 3.5 + Math.sin(t * 3) * 1.2, 0, Math.PI * 2)
g.fill() g.fill()
@ -317,6 +376,7 @@ export class Renderer {
private drawBase(g: CanvasRenderingContext2D, eng: GameEngine): void { private drawBase(g: CanvasRenderingContext2D, eng: GameEngine): void {
const p = eng.pathPx[eng.pathPx.length - 1] const p = eng.pathPx[eng.pathPx.length - 1]
const th = eng.mapDef.theme
const x = p.x - 26 const x = p.x - 26
const y = p.y const y = p.y
g.save() g.save()
@ -326,12 +386,12 @@ export class Renderer {
g.ellipse(x, y + 26, 30, 8, 0, 0, Math.PI * 2) g.ellipse(x, y + 26, 30, 8, 0, 0, Math.PI * 2)
g.fill() g.fill()
// keep walls // keep walls
g.fillStyle = '#8d8577' g.fillStyle = th.baseColor
g.fillRect(x - 24, y - 30, 48, 56) g.fillRect(x - 24, y - 30, 48, 56)
g.fillStyle = '#a49b8b' g.fillStyle = th.baseAccent
g.fillRect(x - 24, y - 30, 48, 8) g.fillRect(x - 24, y - 30, 48, 8)
// crenellations // crenellations
g.fillStyle = '#8d8577' g.fillStyle = th.baseColor
for (let i = 0; i < 4; i++) g.fillRect(x - 24 + i * 13, y - 38, 8, 8) for (let i = 0; i < 4; i++) g.fillRect(x - 24 + i * 13, y - 38, 8, 8)
// gate // gate
g.fillStyle = '#4c3a28' g.fillStyle = '#4c3a28'

View file

@ -1,5 +1,5 @@
import { reactive } from 'vue' import { reactive } from 'vue'
import type { DifficultyId, EnemyKind, MPMode, Phase, Screen, SelectedTowerInfo, TowerKind } from './types' import type { DifficultyId, EnemyKind, MapId, MPMode, Phase, Screen, SelectedTowerInfo, TowerKind } from './types'
/** /**
* Reactive bridge between the (non-reactive) game engine and the Vue UI. * Reactive bridge between the (non-reactive) game engine and the Vue UI.
@ -11,6 +11,7 @@ export const store = reactive({
speed: 1, speed: 1,
muted: false, muted: false,
difficulty: 'normal' as DifficultyId, difficulty: 'normal' as DifficultyId,
mapId: 'meadow' as MapId,
money: 0, money: 0,
lives: 0, lives: 0,
@ -40,6 +41,7 @@ export const store = reactive({
mp: { mp: {
active: false, active: false,
mode: 'coop' as MPMode, mode: 'coop' as MPMode,
mapId: 'meadow' as MapId,
status: '', status: '',
name: '', name: '',
peerName: '', peerName: '',
@ -59,11 +61,17 @@ export const store = reactive({
}, },
}) })
export function bestScoreKey(diff: DifficultyId): string { export function bestScoreKey(diff: DifficultyId, mapId: MapId = 'meadow'): string {
return `trxtd-best-${diff}` return `trxtd-best-${diff}-${mapId}`
} }
export function loadBest(diff: DifficultyId): number { export function loadBest(diff: DifficultyId, mapId: MapId = 'meadow'): number {
if (typeof localStorage === 'undefined') return 0 if (typeof localStorage === 'undefined') return 0
return Number(localStorage.getItem(bestScoreKey(diff)) ?? 0) const val = localStorage.getItem(bestScoreKey(diff, mapId))
if (val !== null) return Number(val)
// backward compatibility with legacy single-map key
if (mapId === 'meadow') {
return Number(localStorage.getItem(`trxtd-best-${diff}`) ?? 0)
}
return 0
} }

View file

@ -4,8 +4,60 @@ export type TargetingMode = 'first' | 'last' | 'strong' | 'close'
export type Phase = 'idle' | 'intermission' | 'wave' | 'gameover' | 'victory' export type Phase = 'idle' | 'intermission' | 'wave' | 'gameover' | 'victory'
export type Screen = 'menu' | 'lobby' | 'game' | 'gameover' | 'victory' export type Screen = 'menu' | 'lobby' | 'game' | 'gameover' | 'victory'
export type DifficultyId = 'easy' | 'normal' | 'hard' export type DifficultyId = 'easy' | 'normal' | 'hard'
export type MapId = 'meadow' | 'desert' | 'frostland' | 'volcano'
export type BiomeId = 'grassland' | 'desert' | 'snow' | 'magma'
export type MPMode = 'coop' | 'duel' export type MPMode = 'coop' | 'duel'
export interface MapTheme {
biome: BiomeId
bgGradient: [string, string]
checkerAlpha: number
gridColor: string
speckleDark: string
speckleLight: string
pathBorder: string
pathEdge: string
pathMain: string
pathDash: string
pathPebbles: [string, string]
portalColor: string
portalRing: string
portalCore: string
baseColor: string
baseAccent: string
treeFoliage: [string, string]
treeTrunk: string
rockColor: string
rockHighlight: string
bushColor: [string, string]
flowers: string[]
obstacleTypes: {
primary: 'tree' | 'rock'
secondary: 'tree' | 'rock'
}
}
export interface MapDef {
id: MapId
name: string
subtitle: string
desc: string
icon: string
theme: MapTheme
waypoints: [number, number][]
flyWaypoints: [number, number][]
decorCounts: {
primaryObstacle: number // trees or boulders
secondaryObstacle: number // rocks or crystals
bush: number
detail: number // flowers or embers/crystals
}
decorDistLimits?: {
primaryDist: [number, number]
secondaryDist: [number, number]
}
}
/** synchronized player actions (lockstep); applied identically on all clients */ /** synchronized player actions (lockstep); applied identically on all clients */
export type MPAction = export type MPAction =
| { type: 'build'; kind: TowerKind; tx: number; ty: number } | { type: 'build'; kind: TowerKind; tx: number; ty: number }