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