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
}
const VALID_MAPS = ['meadow', 'desert', 'frostland', 'volcano']
function sanitizeName(raw) {
if (typeof raw !== 'string') return 'Spieler'
const clean = raw.trim().replace(/[^\p{L}\p{N}_\- ]/gu, '').slice(0, 12)
return clean || 'Spieler'
}
function sanitizeMapId(raw) {
return VALID_MAPS.includes(raw) ? raw : 'meadow'
}
function send(ws, obj) {
if (ws && ws.readyState === 1) {
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.' })
}
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)
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
}
case 'join': {
@ -399,7 +416,7 @@ wss.on('connection', (ws) => {
room.players.push({ ws, id: 1, name: sanitizeName(m.name) })
ws._room = code
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
}
@ -409,7 +426,7 @@ wss.on('connection', (ws) => {
if (room.players[0].ws !== ws) return // only the host may start
const seed = (Math.random() * 1e9) | 0
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
}

View file

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

View file

@ -1,7 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue'
import { MAPS, MAP_ORDER } from '@/game/config'
import { store } from '@/game/store'
import { mpgame } from '@/game/mpgame'
import type { MapId } from '@/game/types'
const modeText = computed(() =>
store.mp.mode === 'coop'
@ -16,6 +18,10 @@ function leave(): void {
function start(): void {
mpgame.requestStart()
}
function pickMap(mId: MapId): void {
mpgame.setMap(mId)
}
</script>
<template>
@ -35,6 +41,25 @@ function start(): void {
<span class="hint">Diesen Code an deinen Mitspieler weitergeben</span>
</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
v-for="p in store.mp.players"
@ -113,6 +138,46 @@ h2 {
flex-direction: column;
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 {
font-size: 11px;
color: var(--text-dim);

View file

@ -1,29 +1,39 @@
<script setup lang="ts">
import { ref } from 'vue'
import { DIFFICULTIES } from '@/game/config'
import { DIFFICULTIES, MAPS, MAP_ORDER } from '@/game/config'
import { engine } from '@/game/engine'
import { mpgame } from '@/game/mpgame'
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>(
(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 mpCode = ref('')
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.startGame(selected.value)
engine.startGame(selected.value, selectedMap.value)
}
async function createRoom(mode: MPMode): Promise<void> {
const name = mpName.value.trim() || 'Spieler'
if (typeof localStorage !== 'undefined') localStorage.setItem('trxtd-name', name)
await mpgame.create(mode, name)
if (typeof localStorage !== 'undefined') {
localStorage.setItem('trxtd-name', name)
localStorage.setItem('trxtd-map', selectedMap.value)
}
await mpgame.create(mode, name, selectedMap.value)
}
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>
</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">
<button
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-desc">{{ d.desc }}</div>
<div class="diff-best">🏆 Bestleistung: {{ loadBest(d.id) || '—' }}</div>
</button>
</div>
@ -123,12 +152,12 @@ async function joinRoom(): Promise<void> {
<style scoped>
.start {
max-width: 760px;
max-width: 860px;
margin: 0 auto;
padding: 32px 16px 48px;
display: flex;
flex-direction: column;
gap: 22px;
gap: 20px;
align-items: center;
}
.hero {
@ -167,18 +196,86 @@ h1 {
margin: 6px 0 0;
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 {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: center;
width: 100%;
}
.diff-card {
background: var(--panel);
border: 2px solid var(--panel-border);
border-radius: 14px;
padding: 14px 16px;
width: 200px;
flex: 1;
min-width: 180px;
max-width: 260px;
text-align: left;
cursor: pointer;
color: var(--text);
@ -202,11 +299,6 @@ h1 {
margin-top: 4px;
min-height: 34px;
}
.diff-best {
margin-top: 8px;
font-size: 12px;
color: #ffd23e;
}
.start-btn {
background: linear-gradient(180deg, #59b34d, #3f8f37);
border: none;
@ -291,16 +383,6 @@ h1 {
color: #ff8a7a;
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 {
display: flex;
gap: 24px;
@ -311,7 +393,7 @@ h1 {
line-height: 1.55;
}
.help-col {
max-width: 330px;
max-width: 360px;
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 12px;
@ -332,4 +414,4 @@ h1 {
.help-col b {
color: var(--text);
}
</style>
</style>

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 COLS = 20
@ -22,27 +22,250 @@ export function playerColor(playerId: number | null): string {
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 MAP_WAYPOINTS: [number, number][] = [
[-1, 5],
[2, 5],
[2, 1],
[6, 1],
[6, 9],
[10, 9],
[10, 1],
[14, 1],
[14, 9],
[17, 9],
[17, 5],
[20, 5],
]
export const MAPS: Record<MapId, MapDef> = {
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],
[2, 5],
[2, 1],
[6, 1],
[6, 9],
[10, 9],
[10, 1],
[14, 1],
[14, 9],
[17, 9],
[17, 5],
[20, 5],
],
flyWaypoints: [
[-1, 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],
},
},
}
/** flying enemies fly straight across the middle */
export const FLY_WAYPOINTS: [number, number][] = [
[-1, 5],
[20, 5],
]
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']

View file

@ -1,9 +1,9 @@
import {
DIFFICULTIES,
ENEMIES,
FLY_WAYPOINTS,
INTERMISSION,
MAP_WAYPOINTS,
MAPS,
MAP_ORDER,
OBSTACLE_COST,
SELL_RATIO,
TILE,
@ -26,6 +26,8 @@ import type {
EnemyKind,
MPAction,
MPMode,
MapDef,
MapId,
Phase,
Projectile,
Pt,
@ -84,6 +86,11 @@ export class GameEngine {
speed = 1
endless = false
difficulty: DifficultyId = 'normal'
mapId: MapId = 'meadow'
get mapDef(): MapDef {
return MAPS[this.mapId] || MAPS.meadow
}
buildType: TowerKind | null = null
hover: { x: number; y: number; tx: number; ty: number; valid: boolean } | null = null
@ -120,14 +127,23 @@ export class GameEngine {
// ---------------------------------------------------------------- map
private buildMap(): void {
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 = FLY_WAYPOINTS.map(toPx)
setMap(mapId: MapId): void {
if (MAPS[mapId]) {
this.mapId = mapId
this.buildMap()
}
}
for (let i = 0; i < MAP_WAYPOINTS.length - 1; i++) {
const [ax, ay] = MAP_WAYPOINTS[i]
const [bx, by] = MAP_WAYPOINTS[i + 1]
private buildMap(): void {
const map = this.mapDef
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 dy = Math.sign(by - ay)
let x = ax
@ -140,10 +156,11 @@ export class GameEngine {
}
}
// deterministic decoration
// deterministic decoration based on map id
this.decor = []
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 => {
let best = Infinity
for (const key of this.pathCells) {
@ -170,10 +187,11 @@ export class GameEngine {
placed++
}
}
tryDecor('tree', 7, 2.1, 99)
tryDecor('rock', 5, 1.6, 99)
tryDecor('bush', 10, 1.05, 99)
tryDecor('flower', 22, 0, 99)
const limits = map.decorDistLimits || { primaryDist: [2.1, 99], secondaryDist: [1.6, 99] }
tryDecor('tree', map.decorCounts.primaryObstacle, limits.primaryDist[0], limits.primaryDist[1])
tryDecor('rock', map.decorCounts.secondaryObstacle, limits.secondaryDist[0], limits.secondaryDist[1])
tryDecor('bush', map.decorCounts.bush, 1.05, 99)
tryDecor('flower', map.decorCounts.detail, 0, 99)
}
private markPathCell(x: number, y: number): void {
@ -236,10 +254,11 @@ export class GameEngine {
// ---------------------------------------------------------------- lifecycle
startGame(diff: DifficultyId): void {
startGame(diff: DifficultyId, mapId: MapId = 'meadow'): void {
const d = DIFFICULTIES[diff]
this.difficulty = diff
this.buildMap() // restores removed obstacles
this.mapId = mapId
this.buildMap() // restores removed obstacles & loads chosen map layout
this.enemies = []
this.towers = []
this.projectiles = []
@ -268,6 +287,7 @@ export class GameEngine {
store.speed = 1
store.muted = sound.muted
store.difficulty = diff
store.mapId = mapId
store.money = this.money
store.lives = this.lives
store.maxLives = this.lives
@ -509,10 +529,10 @@ export class GameEngine {
private finish(win: boolean): void {
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)
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.screen = win ? 'victory' : 'gameover'

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

View file

@ -1,8 +1,9 @@
import type { MPAction, MPMode } from './types'
import type { MapId, MPAction, MPMode } from './types'
export interface RoomInfo {
code: string
mode: MPMode
mapId: MapId
players: { id: number; name: string }[]
you: number
}
@ -11,6 +12,7 @@ export interface StartInfo {
you: number
seed: number
mode: MPMode
mapId: MapId
players: { id: number; name: string }[]
}
@ -122,8 +124,12 @@ export class MPClient {
if (this.connected && this.ws) this.ws.send(JSON.stringify(obj))
}
createRoom(mode: MPMode, name: string): void {
this.send({ t: 'create', mode, name })
createRoom(mode: MPMode, name: string, mapId: MapId = 'meadow'): void {
this.send({ t: 'create', mode, name, mapId })
}
setMap(mapId: MapId): void {
this.send({ t: 'set-map', mapId })
}
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 { Enemy, Tower } from './types'
import type { Enemy, MapTheme, Tower } from './types'
import { mulberry32 } from './utils'
/**
@ -17,6 +17,8 @@ export class Renderer {
private pxScale = 1
private cssW = W
private cssH = H
private currentMapId = ''
private currentTheme: MapTheme = MAPS.meadow.theme
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas
@ -41,7 +43,7 @@ export class Renderer {
this.pxScale = Math.min(3, (this.dpr * cssW) / W)
this.canvas.width = Math.round(W * this.pxScale)
this.canvas.height = Math.round(H * this.pxScale)
this.buildBackground()
this.buildBackground(this.currentTheme)
}
destroy(): void {
@ -52,17 +54,20 @@ export class Renderer {
// ------------------------------------------------------------ background
private buildBackground(): void {
buildBackground(theme?: MapTheme): void {
const c = this.bg
c.width = this.canvas.width
c.height = this.canvas.height
const g = c.getContext('2d')!
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)
grad.addColorStop(0, '#3d6b39')
grad.addColorStop(1, '#48783f')
grad.addColorStop(0, th.bgGradient[0])
grad.addColorStop(1, th.bgGradient[1])
g.fillStyle = grad
g.fillRect(0, 0, W, H)
@ -70,23 +75,23 @@ export class Renderer {
for (let ty = 0; ty < 11; ty++) {
for (let tx = 0; tx < 20; tx++) {
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)
}
}
}
// grass speckles
// biome texture speckles
const rng = mulberry32(1337)
for (let i = 0; i < 500; i++) {
const x = rng() * W
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)
}
// grid lines
g.strokeStyle = 'rgba(0,0,0,0.06)'
g.strokeStyle = th.gridColor
g.lineWidth = 1
for (let x = 0; x <= 20; x++) {
g.beginPath()
@ -104,25 +109,26 @@ export class Renderer {
private drawPath(g: CanvasRenderingContext2D, eng: GameEngine): void {
const pts = eng.pathPx
const th = eng.mapDef.theme
g.lineJoin = 'round'
g.lineCap = 'round'
g.beginPath()
g.moveTo(pts[0].x, pts[0].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.stroke()
g.strokeStyle = '#7a6142'
g.strokeStyle = th.pathEdge
g.lineWidth = TILE * 0.7
g.stroke()
g.strokeStyle = '#c2a276'
g.strokeStyle = th.pathMain
g.lineWidth = TILE * 0.58
g.stroke()
// dashed center line
g.save()
g.strokeStyle = 'rgba(216,195,154,0.55)'
g.strokeStyle = th.pathDash
g.lineWidth = 3
g.setLineDash([12, 16])
g.beginPath()
@ -144,7 +150,7 @@ export class Renderer {
const len = Math.hypot(dx, dy) || 1
const px = a.x + dx * t + (-dy / 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.arc(px, py, 1.2 + rng() * 1.6, 0, Math.PI * 2)
g.fill()
@ -152,29 +158,40 @@ export class Renderer {
}
private drawDecor(g: CanvasRenderingContext2D, eng: GameEngine): void {
const th = eng.mapDef.theme
for (const d of eng.decor) {
const { x, y, s } = d
if (d.type === 'flower') {
const cols = ['#e8657f', '#f5d55b', '#ffffff', '#c17ee0']
const col = cols[Math.floor(d.seed * cols.length)]
const col = th.flowers[Math.floor(d.seed * th.flowers.length)]
g.fillStyle = col
for (let i = 0; i < 3; i++) {
const fx = x + (i - 1) * 6 * s + (d.seed * 10 - 5)
const fy = y + ((i * 13) % 7 - 3) * s
if (th.biome === 'magma') {
// glowing ember spark
g.beginPath()
g.arc(fx, fy, 2.2 * s, 0, Math.PI * 2)
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++) {
const fx = x + (i - 1) * 6 * s + (d.seed * 10 - 5)
const fy = y + ((i * 13) % 7 - 3) * s
g.beginPath()
g.arc(fx, fy, 2.2 * s, 0, Math.PI * 2)
g.fill()
}
}
} else if (d.type === 'bush') {
g.fillStyle = 'rgba(0,0,0,0.15)'
g.beginPath()
g.ellipse(x, y + 8 * s, 11 * s, 4 * s, 0, 0, Math.PI * 2)
g.fill()
g.fillStyle = '#3e7a3a'
g.fillStyle = th.bushColor[0]
g.beginPath()
g.ellipse(x, y, 10 * s, 7 * s, 0, 0, Math.PI * 2)
g.fill()
g.fillStyle = '#4f8f45'
g.fillStyle = th.bushColor[1]
g.beginPath()
g.ellipse(x - 3 * s, y - 2 * s, 6 * s, 4.5 * s, 0, 0, Math.PI * 2)
g.fill()
@ -183,7 +200,7 @@ export class Renderer {
g.beginPath()
g.ellipse(x, y + 10 * s, 13 * s, 4 * s, 0, 0, Math.PI * 2)
g.fill()
g.fillStyle = '#8b9298'
g.fillStyle = th.rockColor
g.beginPath()
g.moveTo(x - 12 * s, y + 8 * 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.closePath()
g.fill()
g.fillStyle = 'rgba(255,255,255,0.22)'
g.fillStyle = th.rockHighlight
g.beginPath()
g.moveTo(x - 8 * s, y - 5 * s)
g.lineTo(x + 1 * s, y - 9 * s)
@ -200,27 +217,63 @@ export class Renderer {
g.closePath()
g.fill()
} else {
// tree
// tree / obstacle
g.fillStyle = 'rgba(0,0,0,0.22)'
g.beginPath()
g.ellipse(x, y + 12 * s, 15 * s, 5 * s, 0, 0, Math.PI * 2)
g.fill()
g.fillStyle = '#6b4526'
g.fillStyle = th.treeTrunk
g.fillRect(x - 3.5 * s, y - 4 * s, 7 * s, 16 * s)
const rng = mulberry32(Math.floor(d.seed * 1e9))
for (let i = 0; i < 4; i++) {
const cx = x + (rng() - 0.5) * 16 * s
const cy = y - 8 * s - rng() * 12 * s
const r = (8 + rng() * 5) * s
g.fillStyle = i % 2 === 0 ? '#2f6b33' : '#3b7c3c'
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++) {
const cx = x + (rng() - 0.5) * 16 * s
const cy = y - 8 * s - rng() * 12 * s
const r = (8 + rng() * 5) * s
g.fillStyle = i % 2 === 0 ? th.treeFoliage[0] : th.treeFoliage[1]
g.beginPath()
g.arc(cx, cy, r, 0, Math.PI * 2)
g.fill()
}
g.fillStyle = 'rgba(255,255,255,0.12)'
g.beginPath()
g.arc(cx, cy, r, 0, Math.PI * 2)
g.arc(x - 4 * s, y - 16 * s, 6 * s, 0, Math.PI * 2)
g.fill()
}
g.fillStyle = 'rgba(255,255,255,0.12)'
g.beginPath()
g.arc(x - 4 * s, y - 16 * s, 6 * s, 0, Math.PI * 2)
g.fill()
}
}
}
@ -228,6 +281,11 @@ export class Renderer {
// ------------------------------------------------------------ main render
render(eng: GameEngine): void {
if (this.currentMapId !== eng.mapId) {
this.currentMapId = eng.mapId
this.buildBackground(eng.mapDef.theme)
}
const g = this.ctx
g.setTransform(this.pxScale, 0, 0, this.pxScale, 0, 0)
g.clearRect(0, 0, W, H)
@ -294,21 +352,22 @@ export class Renderer {
private drawSpawn(g: CanvasRenderingContext2D, eng: GameEngine): void {
const p = eng.pathPx[0]
const th = eng.mapDef.theme
g.save()
g.translate(p.x + 18, p.y)
g.fillStyle = '#2a1e3d'
g.fillStyle = th.portalColor
g.beginPath()
g.ellipse(0, 0, 26, 30, 0, 0, Math.PI * 2)
g.fill()
const t = eng.time * 2
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.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.stroke()
}
g.fillStyle = 'rgba(200,160,255,0.9)'
g.fillStyle = th.portalCore
g.beginPath()
g.arc(0, 0, 3.5 + Math.sin(t * 3) * 1.2, 0, Math.PI * 2)
g.fill()
@ -317,6 +376,7 @@ export class Renderer {
private drawBase(g: CanvasRenderingContext2D, eng: GameEngine): void {
const p = eng.pathPx[eng.pathPx.length - 1]
const th = eng.mapDef.theme
const x = p.x - 26
const y = p.y
g.save()
@ -326,12 +386,12 @@ export class Renderer {
g.ellipse(x, y + 26, 30, 8, 0, 0, Math.PI * 2)
g.fill()
// keep walls
g.fillStyle = '#8d8577'
g.fillStyle = th.baseColor
g.fillRect(x - 24, y - 30, 48, 56)
g.fillStyle = '#a49b8b'
g.fillStyle = th.baseAccent
g.fillRect(x - 24, y - 30, 48, 8)
// 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)
// gate
g.fillStyle = '#4c3a28'

View file

@ -1,5 +1,5 @@
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.
@ -11,6 +11,7 @@ export const store = reactive({
speed: 1,
muted: false,
difficulty: 'normal' as DifficultyId,
mapId: 'meadow' as MapId,
money: 0,
lives: 0,
@ -40,6 +41,7 @@ export const store = reactive({
mp: {
active: false,
mode: 'coop' as MPMode,
mapId: 'meadow' as MapId,
status: '',
name: '',
peerName: '',
@ -59,11 +61,17 @@ export const store = reactive({
},
})
export function bestScoreKey(diff: DifficultyId): string {
return `trxtd-best-${diff}`
export function bestScoreKey(diff: DifficultyId, mapId: MapId = 'meadow'): string {
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
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 Screen = 'menu' | 'lobby' | 'game' | 'gameover' | 'victory'
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 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 */
export type MPAction =
| { type: 'build'; kind: TowerKind; tx: number; ty: number }