feat: initialize TRXTD browser tower defense

This commit is contained in:
Tronax 2026-08-15 16:07:09 +02:00
commit c4347f8420
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
34 changed files with 8302 additions and 0 deletions

60
src/App.vue Normal file
View file

@ -0,0 +1,60 @@
<script setup lang="ts">
import EndOverlay from '@/components/EndOverlay.vue'
import GameCanvas from '@/components/GameCanvas.vue'
import Hud from '@/components/Hud.vue'
import LobbyScreen from '@/components/LobbyScreen.vue'
import MPBar from '@/components/MPBar.vue'
import ObstaclePanel from '@/components/ObstaclePanel.vue'
import PauseOverlay from '@/components/PauseOverlay.vue'
import PiPCanvas from '@/components/PiPCanvas.vue'
import StartScreen from '@/components/StartScreen.vue'
import TowerPanel from '@/components/TowerPanel.vue'
import TowerShop from '@/components/TowerShop.vue'
import { store } from '@/game/store'
</script>
<template>
<div class="app">
<StartScreen v-if="store.screen === 'menu'" />
<LobbyScreen v-else-if="store.screen === 'lobby'" />
<div v-else class="game-layout">
<Hud />
<MPBar />
<div class="canvas-wrap">
<div class="canvas-box">
<GameCanvas />
<TowerPanel />
<ObstaclePanel />
<PiPCanvas />
<PauseOverlay />
<EndOverlay />
</div>
</div>
<TowerShop />
</div>
</div>
</template>
<style scoped>
.app {
height: 100dvh;
overflow: hidden;
}
.game-layout {
height: 100dvh;
padding: 10px 12px 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.canvas-wrap {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
}
.canvas-box {
position: relative;
}
</style>

View file

@ -0,0 +1,144 @@
<script setup lang="ts">
import { store } from '@/game/store'
import { engine } from '@/game/engine'
import { mpgame } from '@/game/mpgame'
function restart(): void {
engine.startGame(store.difficulty)
}
function endless(): void {
engine.continueEndless()
}
function menu(): void {
if (store.mp.active) {
mpgame.leave()
} else {
store.screen = 'menu'
store.result = null
}
}
</script>
<template>
<div v-if="store.result" class="overlay" :class="store.result.win ? 'win' : 'lose'">
<div class="card">
<div class="emoji">{{ store.result.win ? '🏆' : '💀' }}</div>
<h2 v-if="store.mp.active">{{ store.result.win ? 'Sieg!' : 'Verloren' }}</h2>
<h2 v-else-if="store.result.win">Sieg!</h2>
<h2 v-else>Game Over</h2>
<p v-if="store.mp.active" class="msg">{{ store.mp.resultMsg }}</p>
<p v-else-if="store.result.win" class="msg">Alle 20 Wellen abgewehrt die Basis steht!</p>
<p v-else class="msg">Die Basis wurde überrannt. In Welle {{ store.result.wave }}.</p>
<div v-if="!store.mp.active" class="stats">
<div class="stat"><span>Punkte</span><b>{{ store.result.score }}</b></div>
<div class="stat"><span>Wellen</span><b>{{ store.result.wave }}</b></div>
<div class="stat"><span>Abschüsse</span><b>{{ store.result.kills }}</b></div>
<div class="stat">
<span>Rekord</span>
<b>{{ store.result.best }}<template v-if="store.result.score >= store.result.best && store.result.bestBefore < store.result.score"> 🎉 neu!</template></b>
</div>
</div>
<div class="btns">
<template v-if="store.mp.active">
<button class="btn primary" @click="menu">🚪 Zurück zum Menü</button>
</template>
<template v-else>
<button v-if="store.result.win" class="btn primary" @click="endless"> Endlos weiterspielen</button>
<button class="btn" @click="restart">🔁 Nochmal spielen</button>
<button class="btn" @click="menu">🚪 Hauptmenü</button>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(8, 11, 15, 0.72);
backdrop-filter: blur(3px);
border-radius: 10px;
z-index: 10;
}
.card {
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 16px;
padding: 28px 36px;
text-align: center;
max-width: 420px;
animation: pop 0.3s ease;
transform: scale(var(--ui-scale, 1));
}
@keyframes pop {
from { opacity: 0; }
}
.emoji {
font-size: 52px;
}
h2 {
margin: 6px 0 4px;
font-size: 30px;
}
.win h2 { color: #ffd23e; }
.lose h2 { color: #ff7a6e; }
.msg {
color: var(--text-dim);
font-size: 14px;
margin: 0 0 16px;
}
.stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 18px;
}
.stat {
background: var(--panel-inset);
border-radius: 10px;
padding: 10px;
display: flex;
flex-direction: column;
gap: 2px;
}
.stat span {
font-size: 11px;
color: var(--text-dim);
}
.stat b {
font-size: 18px;
}
.btns {
display: flex;
gap: 8px;
justify-content: center;
flex-wrap: wrap;
}
.btn {
background: var(--panel-inset);
border: 1px solid var(--panel-border);
color: var(--text);
font-family: inherit;
font-weight: 700;
font-size: 13px;
border-radius: 10px;
padding: 10px 16px;
cursor: pointer;
}
.btn:hover {
border-color: var(--accent);
}
.btn.primary {
background: linear-gradient(180deg, #e0a83f, #c5832a);
border: none;
color: #fff;
}
</style>

View file

@ -0,0 +1,169 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { engine } from '@/game/engine'
import { H, TOWERS, W, TILE } from '@/game/config'
import { store } from '@/game/store'
import { mpgame, submitAction, uiStartWaveEarly, uiTogglePause, uiUpgradeSelected, uiSellSelected } from '@/game/mpgame'
const el = ref<HTMLCanvasElement | null>(null)
let resizeObserver: ResizeObserver | null = null
/** scale the canvas to fill the available area while keeping the 960:528 ratio */
function fit(): void {
const canvas = el.value
if (!canvas) return
const wrap = canvas.closest('.canvas-wrap') as HTMLElement | null
const availW = wrap ? wrap.clientWidth : W
const availH = wrap ? wrap.clientHeight : H
const scale = Math.max(0.1, Math.min(availW / W, availH / H))
const w = Math.floor(W * scale)
const h = Math.floor(H * scale)
canvas.style.width = `${w}px`
canvas.style.height = `${h}px`
engine.resizeCanvas(w, h)
// overlay panels (tower info, obstacle, end screen) scale with the field
const box = canvas.parentElement
if (box) box.style.setProperty('--ui-scale', String(Math.min(1.75, Math.max(1, scale))))
}
function toGame(e: MouseEvent): { x: number; y: number } {
const c = el.value!
const r = c.getBoundingClientRect()
return {
x: ((e.clientX - r.left) / r.width) * W,
y: ((e.clientY - r.top) / r.height) * H,
}
}
function onMouseMove(e: MouseEvent): void {
const p = toGame(e)
engine.setHover(p.x, p.y)
}
function handleGameClick(x: number, y: number): void {
engine.setHover(x, y)
if (mpgame.active) {
const tx = Math.floor(x / TILE)
const ty = Math.floor(y / TILE)
if (engine.buildType) {
const def = TOWERS[engine.buildType]
if (engine.canPlace(tx, ty) && engine.money >= def.cost) {
submitAction({ type: 'build', kind: engine.buildType, tx, ty })
} else {
engine.sfx('error')
}
} else {
engine.selectAt(tx, ty)
}
return
}
engine.click(x, y)
}
function onClick(e: MouseEvent): void {
const p = toGame(e)
handleGameClick(p.x, p.y)
}
function onTouch(e: TouchEvent): void {
const t = e.touches[0]
if (!t) return
e.preventDefault()
const c = el.value!
const r = c.getBoundingClientRect()
const x = ((t.clientX - r.left) / r.width) * W
const y = ((t.clientY - r.top) / r.height) * H
handleGameClick(x, y)
}
function onKey(e: KeyboardEvent): void {
if (store.screen === 'menu' || store.screen === 'lobby') return
if (e.target instanceof HTMLInputElement) return
switch (e.key) {
case 'Escape':
engine.cancelMode()
break
case ' ':
e.preventDefault()
if (store.phase === 'intermission') uiStartWaveEarly()
else uiTogglePause()
break
case 'p':
case 'P':
uiTogglePause()
break
case 'm':
case 'M':
engine.toggleMute()
break
case 'u':
case 'U':
uiUpgradeSelected()
break
case 'v':
case 'V':
uiSellSelected()
break
case 't':
case 'T':
engine.cycleTargetingSelected()
break
default: {
const kind = Object.values(TOWERS).find((t) => t.hotkey === e.key)
if (kind) {
engine.setBuildType(store.buildType === kind.kind ? null : kind.kind)
}
}
}
}
onMounted(() => {
const canvas = el.value
if (!canvas) return
engine.attach(canvas)
fit()
const wrap = canvas.closest('.canvas-wrap') as HTMLElement | null
if (typeof ResizeObserver !== 'undefined' && wrap) {
resizeObserver = new ResizeObserver(() => fit())
resizeObserver.observe(wrap)
}
window.addEventListener('keydown', onKey)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKey)
resizeObserver?.disconnect()
resizeObserver = null
engine.detach()
})
</script>
<template>
<canvas
ref="el"
class="game-canvas"
:class="{ 'build-mode': store.buildType !== null }"
@mousemove="onMouseMove"
@mouseleave="engine.clearHover()"
@click="onClick"
@contextmenu.prevent="engine.cancelMode()"
@touchstart="onTouch"
/>
</template>
<style scoped>
.game-canvas {
display: block;
width: 960px;
max-width: 100%;
aspect-ratio: 960 / 528;
border-radius: 10px;
background: #2c4a2a;
cursor: crosshair;
touch-action: none;
user-select: none;
}
.game-canvas.build-mode {
cursor: copy;
}
</style>

222
src/components/Hud.vue Normal file
View file

@ -0,0 +1,222 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { store } from '@/game/store'
import { engine } from '@/game/engine'
import { mpgame, uiSetSpeed, uiStartWaveEarly, uiTogglePause } from '@/game/mpgame'
import { DIFFICULTIES, ENEMIES, earlyCallBonus } from '@/game/config'
const confirmExit = ref(false)
const waveLabel = computed(() => {
if (store.endless || store.mp.active) return `Welle ${store.waveNo}`
return `Welle ${store.waveNo}/${store.totalWaves}`
})
const nextBonus = computed(() => (store.phase === 'intermission' ? earlyCallBonus(store.nextWaveIn) : 0))
function previewTooltip(kind: string): string {
return ENEMIES[kind as keyof typeof ENEMIES].name
}
function exitToMenu(): void {
confirmExit.value = false
if (store.mp.active) {
mpgame.leave()
} else {
store.screen = 'menu'
store.result = null
engine.setPaused(false)
}
}
</script>
<template>
<div class="hud">
<div class="hud-stats">
<div class="stat gold" title="Gold">🪙 {{ store.money }}</div>
<div class="stat lives" title="Leben"> {{ store.lives }}</div>
<div class="stat wave" title="Welle">🌊 {{ waveLabel }}</div>
<div v-if="!store.mp.active" class="stat score" title="Punkte">🏆 {{ store.score }}</div>
<div class="stat diff">
{{ store.mp.active ? (store.mp.mode === 'coop' ? '🤝 Coop' : '⚔ 1v1') : DIFFICULTIES[store.difficulty].name }}
</div>
</div>
<div class="hud-wave">
<button
v-if="store.phase === 'intermission'"
class="btn wave-btn pulse"
title="Nächste Welle sofort starten (Leertaste)"
@click="uiStartWaveEarly()"
>
Nächste Welle <span v-if="nextBonus > 0" class="bonus">+{{ nextBonus }}🪙</span>
</button>
<div v-else-if="store.phase === 'wave'" class="wave-running">
<span class="dot" /> Welle läuft
</div>
<div v-if="store.phase === 'intermission'" class="countdown">
automatisch in {{ store.nextWaveIn }}s
</div>
<div v-if="store.nextPreview.length && store.phase === 'intermission'" class="preview">
<span class="preview-label">Danach:</span>
<span
v-for="p in store.nextPreview"
:key="p.kind"
class="preview-chip"
:style="{ background: ENEMIES[p.kind].color }"
:title="previewTooltip(p.kind)"
>
{{ ENEMIES[p.kind].name }} ×{{ p.count }}
</span>
</div>
</div>
<div class="hud-controls">
<button
v-if="!store.mp.active"
class="btn icon"
:title="store.paused ? 'Weiter (P)' : 'Pause (P)'"
@click="uiTogglePause()"
>
{{ store.paused ? '▶' : '⏸' }}
</button>
<button
v-for="s in [1, 2, 3]"
:key="s"
class="btn icon"
:class="{ active: store.speed === s }"
:title="`Geschwindigkeit ${s}x`"
@click="uiSetSpeed(s)"
>
{{ s }}×
</button>
<button class="btn icon" :title="store.muted ? 'Ton an (M)' : 'Ton aus (M)'" @click="engine.toggleMute()">
{{ store.muted ? '🔇' : '🔊' }}
</button>
<template v-if="!confirmExit">
<button class="btn icon danger" title="Zurück zum Hauptmenü" @click="confirmExit = true">🚪</button>
</template>
<template v-else>
<button class="btn icon danger confirm" title="Wirklich beenden?" @click="exitToMenu">Sicher?</button>
<button class="btn icon" @click="confirmExit = false"></button>
</template>
</div>
</div>
</template>
<style scoped>
.hud {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 12px;
padding: 8px 12px;
}
.hud-stats {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.stat {
background: var(--panel-inset);
border-radius: 8px;
padding: 5px 10px;
font-weight: 700;
font-size: 14px;
white-space: nowrap;
}
.stat.gold { color: #ffd23e; }
.stat.lives { color: #ff7a6e; }
.stat.wave { color: #7fd8ff; }
.stat.score { color: #b8f5c0; }
.stat.diff { color: #c9d4e0; font-size: 12px; align-self: center; }
.hud-wave {
flex: 1;
min-width: 200px;
/* feste Höhe, damit das Spielfeld nicht springt, wenn der Wellen-Button kommt/geht */
min-height: 78px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
}
.wave-btn {
background: linear-gradient(180deg, #e0563f, #b93a28);
color: #fff;
font-size: 15px;
}
.wave-btn .bonus {
background: rgba(0, 0, 0, 0.25);
border-radius: 6px;
padding: 1px 6px;
margin-left: 4px;
font-size: 12px;
}
.wave-running {
color: var(--text-dim);
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: #e0563f;
animation: blink 1s infinite;
}
@keyframes blink {
50% { opacity: 0.3; }
}
.countdown {
font-size: 11px;
color: var(--text-dim);
}
.preview {
display: flex;
gap: 4px;
align-items: center;
flex-wrap: wrap;
justify-content: center;
}
.preview-label {
font-size: 11px;
color: var(--text-dim);
}
.preview-chip {
color: #10151c;
font-size: 10.5px;
font-weight: 800;
padding: 2px 6px;
border-radius: 20px;
white-space: nowrap;
}
.hud-controls {
display: flex;
gap: 6px;
}
.icon {
min-width: 40px;
padding: 6px 8px;
}
.icon.active {
background: var(--accent);
color: #10151c;
}
.icon.danger:hover {
background: #a33;
color: #fff;
}
.icon.confirm {
background: #a33;
color: #fff;
min-width: 60px;
}
</style>

View file

@ -0,0 +1,220 @@
<script setup lang="ts">
import { computed } from 'vue'
import { store } from '@/game/store'
import { mpgame } from '@/game/mpgame'
const modeText = computed(() =>
store.mp.mode === 'coop'
? { icon: '🤝', title: 'Coop', desc: 'Gemeinsame Basis, gemeinsames Gold gemeinsam gegen 20 Wellen.' }
: { icon: '⚔', title: '1v1', desc: 'Jeder verteidigt seine eigene Basis. Rushes schicken Gegner zum Feind.' },
)
function leave(): void {
mpgame.leave()
}
function start(): void {
mpgame.requestStart()
}
</script>
<template>
<div class="lobby">
<div class="card">
<div class="mode-head">
<span class="icon">{{ modeText.icon }}</span>
<div>
<h2>{{ modeText.title }}-Lobby</h2>
<p class="desc">{{ modeText.desc }}</p>
</div>
</div>
<div class="code-block">
<span class="label">Raum-Code</span>
<div class="code">{{ store.mp.roomCode }}</div>
<span class="hint">Diesen Code an deinen Mitspieler weitergeben</span>
</div>
<div class="players">
<div
v-for="p in store.mp.players"
:key="p.id"
class="player"
:class="{ me: p.id === store.mp.myId }"
>
<span class="dot" :class="p.id === 0 ? 'p0' : 'p1'" />
<span class="name">{{ p.name }}</span>
<span v-if="p.id === store.mp.myId" class="badge">Du</span>
<span v-if="p.id === 0" class="badge host">Host</span>
</div>
<div v-if="store.mp.players.length < 2" class="waiting">
<span class="spinner" /> Warte auf zweiten Spieler
</div>
</div>
<div class="btns">
<button
v-if="store.mp.isHost"
class="btn primary"
:disabled="store.mp.players.length < 2"
@click="start"
>
Spiel starten
</button>
<div v-else class="guest-hint">Der Host startet das Spiel</div>
<button class="btn" @click="leave">🚪 Verlassen</button>
</div>
</div>
</div>
</template>
<style scoped>
.lobby {
height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.card {
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 16px;
padding: 24px 28px;
width: 420px;
max-width: 100%;
display: flex;
flex-direction: column;
gap: 18px;
}
.mode-head {
display: flex;
gap: 12px;
align-items: center;
}
.mode-head .icon {
font-size: 36px;
}
h2 {
margin: 0;
font-size: 20px;
}
.desc {
margin: 2px 0 0;
color: var(--text-dim);
font-size: 12.5px;
}
.code-block {
text-align: center;
background: var(--panel-inset);
border-radius: 12px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 4px;
}
.label {
font-size: 11px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 1px;
}
.code {
font-size: 42px;
font-weight: 900;
letter-spacing: 12px;
color: var(--accent);
font-family: 'Courier New', monospace;
}
.hint {
font-size: 11px;
color: var(--text-dim);
}
.players {
display: flex;
flex-direction: column;
gap: 6px;
}
.player {
display: flex;
align-items: center;
gap: 8px;
background: var(--panel-inset);
border-radius: 8px;
padding: 8px 10px;
font-weight: 700;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.dot.p0 { background: #4da3ff; }
.dot.p1 { background: #ffb14d; }
.badge {
margin-left: auto;
font-size: 10px;
background: #2a3342;
color: var(--text-dim);
border-radius: 5px;
padding: 2px 6px;
}
.badge.host {
margin-left: 4px;
background: var(--accent);
color: #10151c;
}
.waiting {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-dim);
font-size: 13px;
padding: 6px 2px;
}
.spinner {
width: 12px;
height: 12px;
border: 2px solid var(--panel-border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.btns {
display: flex;
gap: 8px;
justify-content: center;
}
.btn {
background: var(--panel-inset);
border: 1px solid var(--panel-border);
color: var(--text);
font-family: inherit;
font-weight: 700;
font-size: 13px;
border-radius: 10px;
padding: 10px 16px;
cursor: pointer;
}
.btn:hover {
border-color: var(--accent);
}
.btn.primary {
background: linear-gradient(180deg, #59b34d, #3f8f37);
border: none;
color: #fff;
}
.btn.primary:disabled {
background: #3a4450;
color: #79838f;
cursor: not-allowed;
}
.guest-hint {
align-self: center;
color: var(--text-dim);
font-size: 13px;
}
</style>

84
src/components/MPBar.vue Normal file
View file

@ -0,0 +1,84 @@
<script setup lang="ts">
import { store } from '@/game/store'
import { submitAction } from '@/game/mpgame'
function rush(): void {
submitAction({ type: 'rush' })
}
</script>
<template>
<div v-if="store.mp.active" class="mp-bar">
<template v-if="store.mp.mode === 'coop'">
<span class="mode">🤝 Coop</span>
<span class="info">
Mit <b>{{ store.mp.peerName }}</b> · gemeinsames Gold &amp; Leben ·
<span class="p0">Blaue Ring-Türme</span> sind deine, <span class="p1">orange</span> seine
</span>
</template>
<template v-else>
<span class="mode"> 1v1</span>
<span class="opp">
<b>{{ store.mp.peerName }}</b>
{{ store.mp.opponentLives }} · 🪙 {{ store.mp.opponentGold }} · 🌊 {{ store.mp.opponentWave }}
</span>
<button
class="btn rush"
:disabled="store.money < store.mp.rushCost"
:title="'Schickt Läufer-Rush auf die Basis des Gegners'"
@click="rush"
>
Rush senden · 🪙 {{ store.mp.rushCost }}
</button>
</template>
</div>
</template>
<style scoped>
.mp-bar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 10px;
padding: 6px 12px;
font-size: 13px;
}
.mode {
font-weight: 800;
color: var(--accent);
}
.info {
color: var(--text-dim);
}
.info b {
color: var(--text);
}
.p0 { color: #4da3ff; font-weight: 700; }
.p1 { color: #ffb14d; font-weight: 700; }
.opp {
color: var(--text-dim);
}
.opp b {
color: var(--text);
}
.btn.rush {
margin-left: auto;
background: linear-gradient(180deg, #e0563f, #b93a28);
border: none;
color: #fff;
font-weight: 800;
font-family: inherit;
font-size: 13px;
border-radius: 8px;
padding: 7px 14px;
cursor: pointer;
}
.btn.rush:disabled {
background: #3a4450;
color: #79838f;
cursor: not-allowed;
}
</style>

View file

@ -0,0 +1,102 @@
<script setup lang="ts">
import { store } from '@/game/store'
import { engine } from '@/game/engine'
import { uiRemoveObstacle } from '@/game/mpgame'
</script>
<template>
<div v-if="store.obstacle" class="obstacle-panel">
<div class="head">
<span class="icon">{{ store.obstacle.type === 'tree' ? '🌳' : '🪨' }}</span>
<div class="title">
<div class="name">{{ store.obstacle.type === 'tree' ? 'Baum' : 'Fels' }}</div>
<div class="sub">Hindernis</div>
</div>
<button class="close" title="Schließen (Esc)" @click="engine.cancelMode()"></button>
</div>
<p class="desc">Blockiert dieses Feld. Nach dem Entfernen kannst du hier bauen.</p>
<div class="actions">
<button
class="btn remove"
:disabled="store.money < store.obstacle.cost"
@click="uiRemoveObstacle()"
>
Entfernen · 🪙 {{ store.obstacle.cost }}
</button>
</div>
</div>
</template>
<style scoped>
.obstacle-panel {
position: absolute;
right: 10px;
top: 10px;
width: 220px;
background: rgba(16, 21, 28, 0.94);
border: 1px solid var(--panel-border);
border-radius: 12px;
padding: 10px 12px;
z-index: 5;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
transform: scale(var(--ui-scale, 1));
transform-origin: top right;
}
.head {
display: flex;
align-items: center;
gap: 8px;
}
.head .icon {
font-size: 26px;
}
.title {
flex: 1;
}
.name {
font-weight: 800;
font-size: 14px;
}
.sub {
font-size: 11px;
color: var(--text-dim);
}
.close {
background: none;
border: none;
color: var(--text-dim);
font-size: 13px;
cursor: pointer;
padding: 4px;
}
.close:hover {
color: #fff;
}
.desc {
margin: 8px 0 10px;
font-size: 12px;
color: var(--text-dim);
line-height: 1.45;
}
.btn {
width: 100%;
border: none;
border-radius: 8px;
padding: 9px 6px;
font-weight: 800;
font-size: 13px;
cursor: pointer;
font-family: inherit;
}
.btn.remove {
background: linear-gradient(180deg, #59b34d, #3f8f37);
color: #fff;
}
.btn.remove:disabled {
background: #3a4450;
color: #79838f;
cursor: not-allowed;
}
</style>

View file

@ -0,0 +1,88 @@
<script setup lang="ts">
import { store } from '@/game/store'
import { engine } from '@/game/engine'
function menu(): void {
engine.setPaused(false)
store.screen = 'menu'
}
</script>
<template>
<div v-if="store.paused" class="overlay">
<div class="card">
<h2> Pause</h2>
<div class="keys">
<div><b>15</b> Turm bauen</div>
<div><b>Leertaste</b> Welle starten / Pause</div>
<div><b>U</b> Upgrade · <b>V</b> Verkaufen · <b>T</b> Zielmodus</div>
<div><b>P</b> Pause · <b>M</b> Ton · <b>Esc</b> Abbrechen</div>
</div>
<div class="btns">
<button class="btn primary" @click="engine.togglePause()"> Weiter</button>
<button class="btn" @click="menu">🚪 Aufgeben &amp; Menü</button>
</div>
</div>
</div>
</template>
<style scoped>
.overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(8, 11, 15, 0.66);
backdrop-filter: blur(2px);
border-radius: 10px;
z-index: 9;
}
.card {
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 16px;
padding: 26px 34px;
text-align: center;
transform: scale(var(--ui-scale, 1));
}
h2 {
margin: 0 0 14px;
font-size: 26px;
}
.keys {
display: flex;
flex-direction: column;
gap: 6px;
color: var(--text-dim);
font-size: 13px;
margin-bottom: 18px;
}
.keys b {
color: var(--text);
}
.btns {
display: flex;
gap: 8px;
justify-content: center;
}
.btn {
background: var(--panel-inset);
border: 1px solid var(--panel-border);
color: var(--text);
font-family: inherit;
font-weight: 700;
font-size: 13px;
border-radius: 10px;
padding: 10px 16px;
cursor: pointer;
}
.btn:hover {
border-color: var(--accent);
}
.btn.primary {
background: linear-gradient(180deg, #59b34d, #3f8f37);
border: none;
color: #fff;
}
</style>

View file

@ -0,0 +1,70 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { store } from '@/game/store'
import { mpgame } from '@/game/mpgame'
import { H, W } from '@/game/config'
import { Renderer } from '@/game/render'
const el = ref<HTMLCanvasElement | null>(null)
let renderer: Renderer | null = null
let raf = 0
onMounted(() => {
const canvas = el.value
if (!canvas) return
renderer = new Renderer(canvas)
renderer.setCssSize(W * 0.32, H * 0.32)
const loop = (): void => {
if (store.mp.mode === 'duel' && store.screen === 'game') {
const remote = mpgame.remoteEngine
if (remote) renderer?.render(remote)
}
raf = requestAnimationFrame(loop)
}
raf = requestAnimationFrame(loop)
})
onBeforeUnmount(() => {
if (raf) cancelAnimationFrame(raf)
renderer?.destroy()
renderer = null
})
</script>
<template>
<div v-if="store.mp.mode === 'duel'" class="pip">
<div class="pip-label">👁 {{ store.mp.peerName }}</div>
<canvas ref="el" class="pip-canvas" />
</div>
</template>
<style scoped>
.pip {
position: absolute;
left: 10px;
top: 10px;
z-index: 4;
display: flex;
flex-direction: column;
gap: 4px;
}
.pip-label {
background: rgba(16, 21, 28, 0.85);
border: 1px solid var(--panel-border);
border-radius: 6px;
color: var(--text);
font-size: 11px;
font-weight: 700;
padding: 2px 8px;
align-self: flex-start;
}
.pip-canvas {
display: block;
width: 307px;
height: 169px;
border-radius: 8px;
border: 2px solid rgba(255, 255, 255, 0.35);
background: #2c4a2a;
pointer-events: none;
}
</style>

View file

@ -0,0 +1,332 @@
<script setup lang="ts">
import { ref } from 'vue'
import { DIFFICULTIES } 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'
const selected = ref<DifficultyId>(
(typeof localStorage !== 'undefined' && (localStorage.getItem('trxtd-diff') as DifficultyId)) || 'normal',
)
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)
engine.toggleMute()
engine.toggleMute()
engine.startGame(selected.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)
}
async function joinRoom(): Promise<void> {
const code = mpCode.value.trim().toUpperCase()
if (code.length !== 4) {
store.mp.status = 'Bitte einen 4-stelligen Raum-Code eingeben.'
return
}
const name = mpName.value.trim() || 'Spieler'
if (typeof localStorage !== 'undefined') localStorage.setItem('trxtd-name', name)
await mpgame.join(code, name)
}
</script>
<template>
<div class="start">
<div class="hero">
<div class="towers-float">
<span>🏹</span><span>🧨</span><span></span><span></span><span>💫</span>
</div>
<h1>TRXTD</h1>
<p class="subtitle">Tower Defense im Browser verteidige deine Basis gegen 20 Wellen!</p>
</div>
<div class="diffs">
<button
v-for="d in Object.values(DIFFICULTIES)"
:key="d.id"
class="diff-card"
:class="{ active: selected === d.id }"
@click="selected = d.id"
>
<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>
<button class="start-btn" @click="start"> Solo spielen</button>
<div class="mp">
<h3>Mit Freunden spielen</h3>
<div class="mp-row">
<input v-model="mpName" class="mp-input" placeholder="Dein Name" maxlength="12" />
</div>
<div class="mp-row">
<button class="mp-btn coop" @click="createRoom('coop')">🤝 Coop-Raum erstellen</button>
<button class="mp-btn duel" @click="createRoom('duel')"> 1v1-Raum erstellen</button>
</div>
<div class="mp-row">
<input
v-model="mpCode"
class="mp-input code"
placeholder="CODE"
maxlength="4"
@keyup.enter="joinRoom"
/>
<button class="mp-btn join" @click="joinRoom">Beitreten</button>
</div>
<p v-if="store.mp.status" class="mp-status">{{ store.mp.status }}</p>
<p class="mp-hint">
Benötigt den lokalen Server: <code>npm run server</code> funktioniert im Heimnetzwerk.
</p>
</div>
<div class="help">
<div class="help-col">
<h3>Spielziel</h3>
<p>
Gegner laufen vom <b>Portal</b> (links) zur <b>Basis</b> (rechts). Baue Türme neben dem Weg,
um sie aufzuhalten. Jeder Gegner, der durchkommt, kostet Leben bei 0 ist das Spiel vorbei.
</p>
<h3>Steuerung</h3>
<ul>
<li><b>15</b> Turm auswählen, Klick platziert ihn</li>
<li><b>Klick auf Turm</b> Infos, Upgrade, Verkauf, Zielmodus</li>
<li><b>Leertaste</b> nächste Welle früh starten (Bonus-Gold!)</li>
<li><b>P</b> Pause · <b>M</b> Ton · <b>U</b> Upgrade · <b>V</b> Verkaufen · <b>Esc</b> Abbrechen</li>
</ul>
</div>
<div class="help-col">
<h3>Tipps</h3>
<ul>
<li>Die <b>Kanone</b> trifft keine Flieger die fliegen auf halber Höhe ganz geradeaus!</li>
<li><b>Eisturm</b> verlangsamt alles in Reichweite perfekt an Engstellen.</li>
<li>Wellen früh starten bringt Gold, kostet aber Vorbereitungszeit.</li>
<li><b>Bäume und Felsen</b> blockieren Bauplätze anklicken und gegen Gold entfernen.</li>
<li>Verkauf bringt 70% der Investition zurück umbauen lohnt sich.</li>
</ul>
</div>
</div>
</div>
</template>
<style scoped>
.start {
max-width: 760px;
margin: 0 auto;
padding: 32px 16px 48px;
display: flex;
flex-direction: column;
gap: 22px;
align-items: center;
}
.hero {
text-align: center;
}
.towers-float {
font-size: 34px;
display: flex;
gap: 18px;
justify-content: center;
margin-bottom: 6px;
}
.towers-float span {
animation: float 3s ease-in-out infinite;
display: inline-block;
}
.towers-float span:nth-child(2) { animation-delay: 0.3s; }
.towers-float span:nth-child(3) { animation-delay: 0.6s; }
.towers-float span:nth-child(4) { animation-delay: 0.9s; }
.towers-float span:nth-child(5) { animation-delay: 1.2s; }
@keyframes float {
50% { transform: translateY(-8px); }
}
h1 {
font-size: 56px;
margin: 0;
letter-spacing: 6px;
background: linear-gradient(180deg, #ffd23e, #e08a2e);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
text-shadow: none;
}
.subtitle {
color: var(--text-dim);
margin: 6px 0 0;
font-size: 15px;
}
.diffs {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: center;
}
.diff-card {
background: var(--panel);
border: 2px solid var(--panel-border);
border-radius: 14px;
padding: 14px 16px;
width: 200px;
text-align: left;
cursor: pointer;
color: var(--text);
font-family: inherit;
transition: border-color 0.15s ease, transform 0.1s ease;
}
.diff-card:hover {
transform: translateY(-2px);
}
.diff-card.active {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(245, 197, 66, 0.25);
}
.diff-name {
font-weight: 800;
font-size: 17px;
}
.diff-desc {
color: var(--text-dim);
font-size: 12.5px;
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;
color: #fff;
font-size: 20px;
font-weight: 800;
padding: 14px 44px;
border-radius: 14px;
cursor: pointer;
font-family: inherit;
box-shadow: 0 6px 20px rgba(89, 179, 77, 0.35);
}
.start-btn:hover {
filter: brightness(1.1);
}
.mp {
width: 100%;
max-width: 560px;
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 14px;
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 10px;
}
.mp h3 {
margin: 0;
font-size: 15px;
}
.mp-row {
display: flex;
gap: 8px;
}
.mp-input {
flex: 1;
background: var(--panel-inset);
border: 1px solid var(--panel-border);
border-radius: 9px;
color: var(--text);
font-family: inherit;
font-size: 14px;
padding: 9px 12px;
outline: none;
}
.mp-input:focus {
border-color: var(--accent);
}
.mp-input.code {
text-transform: uppercase;
letter-spacing: 4px;
font-weight: 800;
max-width: 140px;
}
.mp-btn {
background: var(--panel-inset);
border: 1px solid var(--panel-border);
color: var(--text);
font-family: inherit;
font-weight: 700;
font-size: 13px;
border-radius: 9px;
padding: 9px 14px;
cursor: pointer;
white-space: nowrap;
}
.mp-btn.coop {
flex: 1;
border-color: #3d6f9e;
}
.mp-btn.duel {
flex: 1;
border-color: #9e503d;
}
.mp-btn.coop:hover,
.mp-btn.duel:hover,
.mp-btn.join:hover {
border-color: var(--accent);
}
.mp-status {
margin: 0;
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;
flex-wrap: wrap;
justify-content: center;
color: var(--text-dim);
font-size: 13px;
line-height: 1.55;
}
.help-col {
max-width: 330px;
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 12px;
padding: 14px 18px;
}
.help-col h3 {
margin: 4px 0 6px;
color: var(--text);
font-size: 14px;
}
.help-col ul {
margin: 0;
padding-left: 18px;
}
.help-col li {
margin-bottom: 4px;
}
.help-col b {
color: var(--text);
}
</style>

View file

@ -0,0 +1,208 @@
<script setup lang="ts">
import { TARGETING_LABEL } from '@/game/engine'
import { store } from '@/game/store'
import { engine } from '@/game/engine'
import { uiSellSelected, uiSetTargeting, uiUpgradeSelected } from '@/game/mpgame'
import type { TargetingMode } from '@/game/types'
const modes: TargetingMode[] = ['first', 'last', 'strong', 'close']
function canAct(): boolean {
// in coop you may only upgrade/sell your own towers
const t = engine.selectedTower()
if (!t) return false
if (t.owner !== null && store.mp.active && t.owner !== store.mp.myId) return false
return true
}
</script>
<template>
<div v-if="store.selected" class="tower-panel">
<div class="head">
<span class="icon">{{ store.selected.icon }}</span>
<div class="title">
<div class="name">{{ store.selected.name }}</div>
<div class="level">
<span v-for="i in 3" :key="i" class="star" :class="{ on: i <= store.selected.level }"></span>
<span class="lvl-text">Level {{ store.selected.level }}</span>
</div>
</div>
<button class="close" title="Schließen (Esc)" @click="engine.cancelMode()"></button>
</div>
<div class="stats">
<div class="row"><span>Schaden</span><b>{{ store.selected.damage }}</b></div>
<div class="row"><span>Reichweite</span><b>{{ store.selected.range }} px</b></div>
<div class="row"><span>Feuerrate</span><b>{{ store.selected.rate.toFixed(1) }}/s</b></div>
<div class="row"><span> DPS</span><b>{{ store.selected.dps }}</b></div>
<div v-if="store.selected.special" class="row"><span>Spezial</span><b>{{ store.selected.special }}</b></div>
<div class="row"><span>Luftziele</span><b>{{ store.selected.hitsFlying ? '✅' : '❌' }}</b></div>
<div class="row"><span>Abschüsse</span><b>{{ store.selected.kills }}</b></div>
<div class="row"><span>Schaden gesamt</span><b>{{ store.selected.damageDealt }}</b></div>
</div>
<div v-if="store.selected.kind !== 'frost'" class="targeting">
<span class="label">Ziel:</span>
<button
v-for="m in modes"
:key="m"
class="chip"
:class="{ active: store.selected.targeting === m }"
@click="uiSetTargeting(m)"
>
{{ TARGETING_LABEL[m] }}
</button>
</div>
<div class="actions">
<button
class="btn up"
:disabled="store.selected.maxLevel || store.money < (store.selected.upgradeCost ?? 0) || !canAct()"
@click="uiUpgradeSelected()"
>
<template v-if="store.selected.maxLevel">Max-Level</template>
<template v-else> Aufrüsten · 🪙 {{ store.selected.upgradeCost }}</template>
</button>
<button class="btn sell" :disabled="!canAct()" @click="uiSellSelected()">
💰 Verkaufen +{{ store.selected.sellValue }}
</button>
</div>
</div>
</template>
<style scoped>
.tower-panel {
position: absolute;
right: 10px;
top: 10px;
width: 240px;
background: rgba(16, 21, 28, 0.94);
border: 1px solid var(--panel-border);
border-radius: 12px;
padding: 10px 12px;
z-index: 5;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
transform: scale(var(--ui-scale, 1));
transform-origin: top right;
}
.head {
display: flex;
align-items: center;
gap: 8px;
}
.head .icon {
font-size: 26px;
}
.title {
flex: 1;
}
.name {
font-weight: 800;
font-size: 14px;
}
.level {
display: flex;
align-items: center;
gap: 1px;
}
.star {
color: #3a4450;
font-size: 12px;
}
.star.on {
color: #ffd23e;
}
.lvl-text {
margin-left: 5px;
font-size: 11px;
color: var(--text-dim);
}
.close {
background: none;
border: none;
color: var(--text-dim);
font-size: 13px;
cursor: pointer;
padding: 4px;
}
.close:hover {
color: #fff;
}
.stats {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 3px;
}
.row {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--text-dim);
}
.row b {
color: var(--text);
font-weight: 700;
}
.targeting {
margin-top: 8px;
display: flex;
gap: 4px;
align-items: center;
flex-wrap: wrap;
}
.label {
font-size: 11px;
color: var(--text-dim);
}
.chip {
background: var(--panel-inset);
border: 1px solid var(--panel-border);
color: var(--text-dim);
font-size: 11px;
font-weight: 700;
border-radius: 6px;
padding: 3px 7px;
cursor: pointer;
}
.chip.active {
background: var(--accent);
color: #10151c;
border-color: var(--accent);
}
.actions {
margin-top: 10px;
display: flex;
gap: 6px;
}
.btn {
flex: 1;
border: none;
border-radius: 8px;
padding: 8px 6px;
font-weight: 800;
font-size: 12px;
cursor: pointer;
font-family: inherit;
}
.btn.up {
background: linear-gradient(180deg, #59b34d, #3f8f37);
color: #fff;
}
.btn.up:disabled {
background: #3a4450;
color: #79838f;
cursor: not-allowed;
}
.btn.sell {
background: var(--panel-inset);
color: #ffd23e;
border: 1px solid var(--panel-border);
}
.btn.sell:hover {
border-color: #ffd23e;
}
</style>

View file

@ -0,0 +1,98 @@
<script setup lang="ts">
import { TOWERS, TOWER_ORDER } from '@/game/config'
import { store } from '@/game/store'
import { engine } from '@/game/engine'
function pick(kind: typeof TOWER_ORDER[number]): void {
engine.setBuildType(store.buildType === kind ? null : kind)
}
</script>
<template>
<div class="shop">
<button
v-for="kind in TOWER_ORDER"
:key="kind"
class="shop-card"
:class="{
active: store.buildType === kind,
disabled: store.money < TOWERS[kind].cost,
}"
:title="TOWERS[kind].desc"
@click="pick(kind)"
>
<span class="icon">{{ TOWERS[kind].icon }}</span>
<span class="name">{{ TOWERS[kind].name }}</span>
<span class="cost">🪙 {{ TOWERS[kind].cost }}</span>
<span class="hotkey">{{ TOWERS[kind].hotkey }}</span>
<span class="stats">
{{ TOWERS[kind].levels[0].damage }} Schaden · {{ TOWERS[kind].levels[0].range }}px
<template v-if="!TOWERS[kind].hitsFlying"> · kein Luft</template>
</span>
</button>
</div>
</template>
<style scoped>
.shop {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.shop-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
background: var(--panel);
border: 2px solid var(--panel-border);
border-radius: 12px;
padding: 8px 10px;
min-width: 118px;
cursor: pointer;
color: var(--text);
font-family: inherit;
transition: transform 0.08s ease, border-color 0.12s ease;
}
.shop-card:hover {
transform: translateY(-2px);
border-color: var(--accent-dim);
}
.shop-card.active {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(245, 197, 66, 0.25);
background: #2a2415;
}
.shop-card.disabled {
opacity: 0.55;
}
.icon {
font-size: 24px;
line-height: 1.2;
}
.name {
font-weight: 700;
font-size: 13px;
}
.cost {
color: #ffd23e;
font-weight: 700;
font-size: 13px;
}
.stats {
font-size: 10.5px;
color: var(--text-dim);
white-space: nowrap;
}
.hotkey {
position: absolute;
top: 4px;
right: 6px;
font-size: 10px;
color: var(--text-dim);
border: 1px solid var(--panel-border);
border-radius: 4px;
padding: 0 4px;
}
</style>

303
src/game/config.ts Normal file
View file

@ -0,0 +1,303 @@
import type { DifficultyDef, DifficultyId, EnemyDef, EnemyKind, TowerDef, TowerKind, WaveGroup } from './types'
export const TILE = 48
export const COLS = 20
export const ROWS = 11
export const W = COLS * TILE // 960
export const H = ROWS * TILE // 528
export const TOTAL_WAVES = 20
export const INTERMISSION = 20 // seconds between waves
export const SELL_RATIO = 0.7
/** cost to remove blocking obstacles */
export const OBSTACLE_COST: Record<'tree' | 'rock', number> = {
tree: 60,
rock: 40,
}
/** 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],
]
/** flying enemies fly straight across the middle */
export const FLY_WAYPOINTS: [number, number][] = [
[-1, 5],
[20, 5],
]
export const TOWER_ORDER: TowerKind[] = ['arrow', 'cannon', 'frost', 'tesla', 'laser']
export const TOWERS: Record<TowerKind, TowerDef> = {
arrow: {
kind: 'arrow',
name: 'Bogenturm',
icon: '🏹',
desc: 'Günstiger Standardturm mit schnellen Einzelschüssen.',
cost: 50,
upgradeCost: [60, 110],
levels: [
{ damage: 12, range: 110, rate: 1.7 },
{ damage: 22, range: 120, rate: 2.0 },
{ damage: 38, range: 132, rate: 2.4 },
],
hitsFlying: true,
projectileSpeed: 460,
color: '#9a6a33',
accent: '#e8c27a',
hotkey: '1',
},
cannon: {
kind: 'cannon',
name: 'Kanone',
icon: '🧨',
desc: 'Flächenschaden durch Explosion trifft aber keine Flieger.',
cost: 110,
upgradeCost: [100, 180],
levels: [
{ damage: 28, range: 118, rate: 0.55 },
{ damage: 50, range: 128, rate: 0.62 },
{ damage: 92, range: 140, rate: 0.7 },
],
hitsFlying: false,
projectileSpeed: 300,
splash: [42, 48, 56],
color: '#57616e',
accent: '#ff9c40',
hotkey: '2',
},
frost: {
kind: 'frost',
name: 'Eisturm',
icon: '❄️',
desc: 'Frostpuls verlangsamt ALLE Gegner in Reichweite (auch Flieger).',
cost: 80,
upgradeCost: [70, 130],
levels: [
{ damage: 5, range: 95, rate: 0.9 },
{ damage: 9, range: 105, rate: 1.0 },
{ damage: 15, range: 118, rate: 1.1 },
],
hitsFlying: true,
slowFactor: [0.55, 0.45, 0.35],
slowDuration: [1.5, 1.9, 2.3],
color: '#4d8fb5',
accent: '#aef0ff',
hotkey: '3',
},
tesla: {
kind: 'tesla',
name: 'Teslaturm',
icon: '⚡',
desc: 'Kettenblitz springt auf mehrere Gegner über.',
cost: 130,
upgradeCost: [120, 200],
levels: [
{ damage: 22, range: 105, rate: 1.0 },
{ damage: 38, range: 115, rate: 1.1 },
{ damage: 66, range: 128, rate: 1.2 },
],
hitsFlying: true,
chain: [3, 4, 5],
chainRange: 100,
color: '#6a5fb0',
accent: '#c8b4ff',
hotkey: '4',
},
laser: {
kind: 'laser',
name: 'Laserturm',
icon: '💫',
desc: 'Soforttreffer mit hoher Reichweite und hohem Schaden.',
cost: 160,
upgradeCost: [150, 260],
levels: [
{ damage: 60, range: 170, rate: 0.5 },
{ damage: 105, range: 185, rate: 0.55 },
{ damage: 185, range: 205, rate: 0.6 },
],
hitsFlying: true,
color: '#b0483f',
accent: '#ff8a7a',
hotkey: '5',
},
}
export const ENEMIES: Record<EnemyKind, EnemyDef> = {
normal: {
kind: 'normal',
name: 'Kriecher',
hp: 55,
speed: 55,
reward: 8,
radius: 11,
dmg: 1,
flying: false,
color: '#58c14b',
},
fast: {
kind: 'fast',
name: 'Läufer',
hp: 34,
speed: 105,
reward: 9,
radius: 9,
dmg: 1,
flying: false,
color: '#ffd23e',
},
tank: {
kind: 'tank',
name: 'Panzer',
hp: 210,
speed: 34,
reward: 20,
radius: 14,
dmg: 2,
flying: false,
color: '#7d8894',
},
flyer: {
kind: 'flyer',
name: 'Flieger',
hp: 48,
speed: 72,
reward: 10,
radius: 10,
dmg: 1,
flying: true,
color: '#b06ee8',
},
boss: {
kind: 'boss',
name: 'Boss',
hp: 1500,
speed: 42,
reward: 130,
radius: 20,
dmg: 5,
flying: false,
color: '#d84b3f',
},
}
/** hp multiplier for wave n */
export function waveHpScale(n: number): number {
const m = n - 1
return 1 + 0.18 * m + 0.02 * m * m
}
/** speed multiplier for wave n */
export function waveSpeedScale(n: number): number {
return Math.min(1.32, 1 + 0.008 * (n - 1))
}
/** reward multiplier for wave n */
export function waveRewardScale(n: number): number {
return 1 + 0.04 * (n - 1)
}
export function waveClearBonus(n: number): number {
return 35 + 8 * n
}
export function earlyCallBonus(remainingSeconds: number): number {
return Math.round(remainingSeconds * 2.5)
}
const G = (kind: EnemyKind, count: number, interval: number): WaveGroup => ({ kind, count, interval })
/** hand-tuned waves 1..20 */
export const WAVES: WaveGroup[][] = [
/* 1 */ [G('normal', 8, 1.1)],
/* 2 */ [G('normal', 12, 0.9)],
/* 3 */ [G('normal', 8, 0.9), G('fast', 5, 0.7)],
/* 4 */ [G('fast', 10, 0.55), G('normal', 8, 0.8)],
/* 5 */ [G('normal', 10, 0.75), G('tank', 3, 2.2)],
/* 6 */ [G('fast', 12, 0.5), G('tank', 4, 2.0)],
/* 7 */ [G('flyer', 8, 0.9), G('normal', 10, 0.7)],
/* 8 */ [G('fast', 10, 0.5), G('flyer', 10, 0.7)],
/* 9 */ [G('tank', 6, 1.6), G('fast', 12, 0.45), G('normal', 10, 0.6)],
/* 10 */ [G('boss', 1, 1), G('normal', 12, 0.8)],
/* 11 */ [G('flyer', 14, 0.55), G('fast', 12, 0.45)],
/* 12 */ [G('tank', 8, 1.3), G('normal', 16, 0.5)],
/* 13 */ [G('fast', 20, 0.35), G('flyer', 12, 0.5)],
/* 14 */ [G('tank', 10, 1.1), G('fast', 16, 0.4)],
/* 15 */ [G('boss', 2, 8), G('flyer', 10, 0.5)],
/* 16 */ [G('normal', 24, 0.35), G('tank', 8, 1.0)],
/* 17 */ [G('fast', 24, 0.3), G('flyer', 16, 0.45)],
/* 18 */ [G('tank', 12, 0.9), G('normal', 20, 0.4)],
/* 19 */ [G('fast', 20, 0.3), G('flyer', 20, 0.4), G('tank', 8, 0.9)],
/* 20 */ [G('tank', 8, 1.0), G('boss', 2, 8), G('fast', 16, 0.35)],
]
/** endless mode waves after 20 */
export function endlessWave(n: number): WaveGroup[] {
const m = n - 20
const groups: WaveGroup[] = []
if (n % 5 === 0) groups.push(G('boss', 1 + Math.floor(m / 5), 8))
groups.push(G('tank', 8 + m * 2, Math.max(0.5, 1.1 - m * 0.02)))
groups.push(G('fast', 18 + m * 3, Math.max(0.15, 0.32 - m * 0.01)))
groups.push(G('flyer', 14 + m * 2, Math.max(0.25, 0.45 - m * 0.01)))
groups.push(G('normal', 20 + m * 4, Math.max(0.18, 0.35 - m * 0.01)))
return groups
}
export function getWave(n: number): WaveGroup[] {
if (n <= WAVES.length) return WAVES[n - 1]
return endlessWave(n)
}
export const DIFFICULTIES: Record<DifficultyId, DifficultyDef> = {
easy: {
id: 'easy',
name: 'Leicht',
desc: '30 Leben, mehr Startgold, schwächere Gegner',
lives: 30,
money: 320,
hpMul: 0.85,
},
normal: {
id: 'normal',
name: 'Normal',
desc: '20 Leben, ausgewogenes Balancing',
lives: 20,
money: 260,
hpMul: 1,
},
hard: {
id: 'hard',
name: 'Schwer',
desc: '12 Leben, weniger Gold, zähere Gegner',
lives: 12,
money: 220,
hpMul: 1.15,
},
}
export const ENEMY_COLORS: Record<EnemyKind, string> = {
normal: '#58c14b',
fast: '#ffd23e',
tank: '#7d8894',
flyer: '#b06ee8',
boss: '#d84b3f',
}
export const ENEMY_ICONS: Record<EnemyKind, string> = {
normal: '🟢',
fast: '🟡',
tank: '⬜',
flyer: '🟣',
boss: '🔴',
}

1293
src/game/engine.ts Normal file

File diff suppressed because it is too large Load diff

404
src/game/mpgame.ts Normal file
View file

@ -0,0 +1,404 @@
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'
/** simulation tick rate in multiplayer (both clients advance identical ticks) */
const TICK_DT = 1 / 30
/** actions are applied DELAY ticks after sending so both clients hold them in order */
const DELAY_TICKS = 3
/** coop: enemies are tougher because two players defend together */
const COOP_HP_MUL = 1.7
/** 1v1 rush pricing */
const RUSH_BASE_COST = 60
const RUSH_COST_PER_WAVE = 8
interface InboxEntry {
seq: number
from: number
tick: number
a: MPAction
}
/**
* Multiplayer controller.
*
* Coop: one shared engine both players build on the same board with a shared
* gold pot and shared lives (towers belong to their builder).
* Duel (1v1): each player defends their own board; both boards are simulated
* on both clients (deterministic lockstep) so you can watch the opponent live.
*/
class MpGameController {
private net = new MPClient({
onRoom: (info) => this.handleRoom(info),
onStart: (info) => this.handleStart(info),
onAct: (msg) => {
// guard inbox against excessive queuing
if (this.inbox.length < 500) {
this.inbox.push(msg)
}
},
onProg: (_from, tick) => {
if (tick > this.net.remoteTick) this.net.remoteTick = tick
},
onPeerLeft: () => this.handlePeerLeft(),
onError: (msg) => this.setLobbyError(msg),
})
active = false
mode: MPMode = 'coop'
engines: GameEngine[] = []
remoteEngine: GameEngine | null = null
myId = 0
private myTick = 0
private acc = 0
private inbox: InboxEntry[] = []
private heartbeat: ReturnType<typeof setInterval> | null = null
private ended = false
private connecting = false
get isHost(): boolean {
return store.mp.myId === 0
}
// ---------------------------------------------------------------- lobby
private setLobbyError(msg: string): void {
if (!this.active) {
store.mp.status = msg
store.mp.inLobby = false
store.screen = 'menu'
this.net.close()
}
}
private handleRoom(info: { code: string; mode: MPMode; players: { id: number; name: string }[]; you: number }): void {
store.mp.roomCode = info.code
store.mp.mode = info.mode
store.mp.players = info.players
store.mp.myId = info.you
store.mp.isHost = info.you === 0
store.mp.inLobby = true
store.mp.status = ''
store.screen = 'lobby'
}
async create(mode: MPMode, name: string): 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.active = false
this.net.createRoom(mode, name)
} catch (err) {
store.mp.status = err instanceof Error ? err.message : 'Verbindung fehlgeschlagen.'
} finally {
this.connecting = false
}
}
async join(code: string, name: string): 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.active = false
this.net.joinRoom(code, name)
} catch (err) {
store.mp.status = err instanceof Error ? err.message : 'Verbindung fehlgeschlagen.'
} finally {
this.connecting = false
}
}
requestStart(): void {
this.net.startGame()
}
leave(): void {
this.disposeGame()
this.net.leave()
this.resetStoreMp()
store.screen = 'menu'
store.result = null
}
private resetStoreMp(): void {
store.mp.active = false
store.mp.inLobby = false
store.mp.status = ''
store.mp.roomCode = ''
store.mp.players = []
store.mp.resultMsg = ''
}
// ---------------------------------------------------------------- game start
private handleStart(info: { you: number; mode: MPMode; players: { id: number; name: string }[] }): void {
this.active = true
this.ended = false
this.mode = info.mode
this.myId = info.you
this.myTick = 0
this.acc = 0
this.inbox = []
const me = info.players.find((p) => p.id === info.you)
const peer = info.players.find((p) => p.id !== info.you)
if (info.mode === 'coop') {
engine.startGame('normal')
engine.mpGameActive = true
engine.mpMode = 'coop'
engine.localPlayerId = info.you
engine.coopHpMul = COOP_HP_MUL
engine.mpController = this
this.engines = [engine]
this.remoteEngine = null
} else {
engine.startGame('normal')
engine.mpGameActive = true
engine.mpMode = 'duel'
engine.localPlayerId = info.you
engine.mpController = this
const remote = new GameEngine()
remote.startGame('normal')
remote.mpGameActive = true
remote.mpMode = 'duel'
remote.localPlayerId = 1 - info.you
remote.ghost = true
this.engines = [engine, remote]
this.remoteEngine = remote
}
store.mp.active = true
store.mp.mode = info.mode
store.mp.name = me?.name ?? 'Du'
store.mp.peerName = peer?.name ?? 'Gegner'
store.mp.myId = info.you
store.mp.resultMsg = ''
store.mp.opponentLives = this.remoteEngine ? this.remoteEngine.lives : 0
store.screen = 'game'
store.paused = false
sound.play('wave')
this.net.remoteTick = -1
if (this.heartbeat) clearInterval(this.heartbeat)
this.heartbeat = setInterval(() => this.net.sendProgress(this.myTick), 100)
engine.announce(
info.mode === 'coop' ? 'Coop-Modus!' : '1v1-Duell!',
info.mode === 'coop' ? 'Gemeinsames Gold Teamwork!' : 'Verteidige deine Basis!',
)
}
private disposeGame(): void {
if (this.heartbeat) clearInterval(this.heartbeat)
this.heartbeat = null
for (const e of this.engines) {
e.mpController = null
e.mpGameActive = false
e.mpMode = 'solo'
e.coopHpMul = 1
e.ghost = false
e.localPlayerId = 0
e.setSpeedLocal(1)
}
this.engines = []
this.remoteEngine = null
this.active = false
this.ended = true
this.inbox = []
}
private handlePeerLeft(): void {
if (this.active && !this.ended) {
const msg =
this.mode === 'coop' ? 'Dein Mitspieler hat das Spiel verlassen.' : 'Gegner hat verlassen du gewinnst!'
this.endGame(this.mode === 'duel', msg)
} else if (store.mp.inLobby) {
// peer left the lobby: back to room view with only us
store.mp.players = store.mp.players.filter((p) => p.id === store.mp.myId)
}
}
// ---------------------------------------------------------------- lockstep loop
/** called from engine.step() every animation frame */
frame(dtReal: number): void {
if (!this.active || this.ended) return
this.acc = Math.min(this.acc + dtReal * engine.speed, 0.4)
while (this.acc >= TICK_DT) {
this.acc -= TICK_DT
this.myTick++
const due = this.inbox
.filter((m) => m.tick <= this.myTick)
.sort((p, q) => p.tick - q.tick || p.seq - q.seq)
this.inbox = this.inbox.filter((m) => m.tick > this.myTick)
for (const m of due) this.route(m.a, m.from)
for (const e of this.engines) e.update(TICK_DT)
}
this.syncDuelInfo()
this.checkEnd()
}
/** applies an action on the right board(s) must behave identically on all clients */
private route(a: MPAction, from: number): void {
if (a.type === 'rush') {
this.doRush(from)
return
}
if (a.type === 'speed') {
for (const e of this.engines) e.setSpeedLocal(a.s)
return
}
if (this.mode === 'coop') {
this.engines[0].applyAction(a, from)
return
}
if (a.type === 'wave') {
// duel: waves always start on BOTH boards (stays symmetric)
for (const e of this.engines) e.applyAction(a, from)
return
}
this.engines[from]?.applyAction(a, from)
}
private doRush(from: number): void {
const sender = this.engines[from]
const target = this.engines[1 - from]
if (!sender || !target) return
const cost = this.rushCost(sender)
if (sender.money < cost) {
if (from === this.myId) sound.play('error')
return
}
sender.money -= cost
target.spawnRush()
}
rushCost(e: GameEngine): number {
return RUSH_BASE_COST + e.waveNo * RUSH_COST_PER_WAVE
}
private syncDuelInfo(): void {
if (this.mode !== 'duel' || !this.remoteEngine) return
store.mp.opponentLives = this.remoteEngine.lives
store.mp.opponentGold = Math.floor(this.remoteEngine.money)
store.mp.opponentWave = this.remoteEngine.waveNo
store.mp.rushCost = this.rushCost(engine)
}
// ---------------------------------------------------------------- end conditions
private checkEnd(): void {
if (this.ended) return
if (this.mode === 'coop') {
if (engine.phase === 'gameover') this.endGame(false, 'Eure Basis ist gefallen…')
else if (engine.phase === 'victory') this.endGame(true, 'Alle 20 Wellen gemeinsam geschafft!')
return
}
const mine = this.engines[this.myId]
const theirs = this.engines[1 - this.myId]
if (!mine || !theirs) return
if (mine.phase === 'gameover') {
this.endGame(false, `${store.mp.peerName} hat deine Basis zerstört.`)
} else if (theirs.phase === 'gameover') {
this.endGame(true, `Basis von ${store.mp.peerName} zerstört du gewinnst!`)
} else if (mine.phase === 'victory' && theirs.phase === 'victory') {
if (mine.lives > theirs.lives) this.endGame(true, 'Nach 20 Wellen hast du mehr Leben übrig!')
else if (theirs.lives > mine.lives) this.endGame(false, 'Nach 20 Wellen hat der Gegner mehr Leben übrig.')
else this.endGame(mine.score >= theirs.score, 'Gleichstand nach Leben die Punkte entscheiden!')
}
}
private endGame(win: boolean, msg: string): void {
this.ended = true
if (this.heartbeat) clearInterval(this.heartbeat)
this.heartbeat = null
store.mp.resultMsg = msg
store.result = {
win,
score: engine.score,
wave: engine.waveNo,
kills: engine.kills,
best: 0,
bestBefore: 0,
}
store.screen = win ? 'victory' : 'gameover'
sound.play(win ? 'victory' : 'defeat')
}
// ---------------------------------------------------------------- outgoing actions
send(a: MPAction): void {
// immediate local precheck for responsive error feedback; the authoritative
// application happens when the server-relayed action reaches its tick
if (a.type === 'build') {
const ok = engine.canPlace(a.tx, a.ty) && engine.money >= 50 // cost check repeated on apply
if (!ok) {
sound.play('error')
return
}
}
this.net.sendAction(a, this.myTick + DELAY_TICKS)
}
}
export const mpgame = new MpGameController()
// ------------------------------------------------------------------ UI facade
// Components call these; they route to the network in multiplayer and to the
// engine directly in solo mode.
export function submitAction(a: MPAction): void {
if (mpgame.active) mpgame.send(a)
else engine.applyLocal(a)
}
export function uiStartWaveEarly(): void {
if (mpgame.active) mpgame.send({ type: 'wave' })
else engine.startWave(true)
}
export function uiSetSpeed(s: number): void {
if (mpgame.active) mpgame.send({ type: 'speed', s })
else engine.setSpeed(s)
}
export function uiTogglePause(): void {
if (!mpgame.active) engine.togglePause()
}
export function uiUpgradeSelected(): void {
const t = engine.selectedTower()
if (!t) return
submitAction({ type: 'upgrade', towerId: t.id })
}
export function uiSellSelected(): void {
const t = engine.selectedTower()
if (!t) return
submitAction({ type: 'sell', towerId: t.id })
}
export function uiSetTargeting(mode: TargetingMode): void {
const t = engine.selectedTower()
if (!t) return
submitAction({ type: 'targeting', towerId: t.id, mode })
}
export function uiRemoveObstacle(): void {
const ob = engine.selectedObstacle
if (!ob) return
submitAction({ type: 'obstacle', tx: ob.tx, ty: ob.ty })
}

157
src/game/net.ts Normal file
View file

@ -0,0 +1,157 @@
import type { MPAction, MPMode } from './types'
export interface RoomInfo {
code: string
mode: MPMode
players: { id: number; name: string }[]
you: number
}
export interface StartInfo {
you: number
seed: number
mode: MPMode
players: { id: number; name: string }[]
}
export interface ActMsg {
seq: number
from: number
/** simulation tick at which this action must be applied */
tick: number
a: MPAction
}
export interface NetHandlers {
onRoom: (info: RoomInfo) => void
onStart: (info: StartInfo) => void
onAct: (msg: ActMsg) => void
onProg: (from: number, tick: number) => void
onPeerLeft: () => void
onError: (msg: string) => void
}
export function serverUrl(): string {
if (typeof location === 'undefined') return 'ws://localhost:3001'
const custom = new URLSearchParams(location.search).get('server')
if (custom) return custom
return `ws://${location.hostname}:3001`
}
/** Thin WebSocket wrapper for the TRXTD room/action protocol. */
export class MPClient {
private ws: WebSocket | null = null
remoteTick = -1
handlers: NetHandlers
constructor(handlers: NetHandlers) {
this.handlers = handlers
}
get connected(): boolean {
return this.ws !== null && this.ws.readyState === 1
}
connect(url = serverUrl()): Promise<void> {
this.close()
return new Promise((resolve, reject) => {
let ws: WebSocket
try {
ws = new WebSocket(url)
} catch (err) {
reject(err)
return
}
const timeout = setTimeout(() => {
ws.close()
reject(new Error('Zeitüberschreitung beim Verbinden.'))
}, 4000)
ws.onopen = () => {
clearTimeout(timeout)
this.ws = ws
resolve()
}
ws.onerror = () => {
clearTimeout(timeout)
reject(new Error('Server nicht erreichbar (npm run server).'))
}
ws.onclose = () => {
if (this.ws === ws) this.ws = null
}
ws.onmessage = (ev) => this.onMessage(String(ev.data))
})
}
private onMessage(raw: string): void {
let m: Record<string, unknown>
try {
m = JSON.parse(raw)
} catch {
return
}
switch (m.t) {
case 'room':
this.handlers.onRoom(m as unknown as RoomInfo)
break
case 'start':
this.handlers.onStart(m as unknown as StartInfo)
break
case 'act':
this.handlers.onAct(m as unknown as ActMsg)
break
case 'prog':
this.handlers.onProg(Number(m.from), Number(m.tick))
break
case 'peer-left':
this.handlers.onPeerLeft()
break
case 'error':
this.handlers.onError(String(m.msg))
break
}
}
private send(obj: Record<string, unknown>): void {
if (this.connected && this.ws) this.ws.send(JSON.stringify(obj))
}
createRoom(mode: MPMode, name: string): void {
this.send({ t: 'create', mode, name })
}
joinRoom(code: string, name: string): void {
this.send({ t: 'join', code, name })
}
startGame(): void {
this.send({ t: 'start' })
}
sendAction(a: MPAction, targetTick: number): void {
this.send({ t: 'act', tick: targetTick, a })
}
sendProgress(myTick: number): void {
this.send({ t: 'prog', tick: myTick })
}
leave(): void {
this.send({ t: 'leave' })
this.close()
}
close(): void {
if (this.ws) {
this.ws.onclose = null
this.ws.onerror = null
this.ws.onmessage = null
try {
this.ws.close()
} catch {
/* ignore */
}
this.ws = null
}
this.remoteTick = -1
}
}

993
src/game/render.ts Normal file
View file

@ -0,0 +1,993 @@
import { ENEMIES, H, TILE, TOWERS, W } from './config'
import type { GameEngine } from './engine'
import type { Enemy, Tower } from './types'
import { mulberry32 } from './utils'
/**
* Draws the whole game to a canvas. Static background (grass, path, decor)
* is pre-rendered once to an offscreen canvas. The backing store resolution
* follows the CSS size of the canvas so the game stays sharp at any scale.
*/
export class Renderer {
private canvas: HTMLCanvasElement
private ctx: CanvasRenderingContext2D
private bg: HTMLCanvasElement
private dpr = 1
/** physical pixels per logical game pixel (960x528 coordinate space) */
private pxScale = 1
private cssW = W
private cssH = H
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas
this.ctx = canvas.getContext('2d')!
this.dpr = Math.min(2, (typeof window !== 'undefined' && window.devicePixelRatio) || 1)
this.bg = document.createElement('canvas')
this.setCssSize(W, H)
if (typeof window !== 'undefined') {
window.addEventListener('resize', this.onResize)
}
}
private onResize = (): void => {
this.dpr = Math.min(2, (typeof window !== 'undefined' && window.devicePixelRatio) || 1)
this.setCssSize(this.cssW, this.cssH)
}
/** adapts backing-store resolution to the element's CSS size (keeps 960:528 ratio) */
setCssSize(cssW: number, cssH: number): void {
this.cssW = cssW
this.cssH = cssH
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()
}
destroy(): void {
if (typeof window !== 'undefined') {
window.removeEventListener('resize', this.onResize)
}
}
// ------------------------------------------------------------ background
private buildBackground(): 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 grad = g.createLinearGradient(0, 0, 0, H)
grad.addColorStop(0, '#3d6b39')
grad.addColorStop(1, '#48783f')
g.fillStyle = grad
g.fillRect(0, 0, W, H)
// subtle checkerboard
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.fillRect(tx * TILE, ty * TILE, TILE, TILE)
}
}
}
// grass 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.fillRect(x, y, 2, 2)
}
// grid lines
g.strokeStyle = 'rgba(0,0,0,0.06)'
g.lineWidth = 1
for (let x = 0; x <= 20; x++) {
g.beginPath()
g.moveTo(x * TILE + 0.5, 0)
g.lineTo(x * TILE + 0.5, H)
g.stroke()
}
for (let y = 0; y <= 11; y++) {
g.beginPath()
g.moveTo(0, y * TILE + 0.5)
g.lineTo(W, y * TILE + 0.5)
g.stroke()
}
}
private drawPath(g: CanvasRenderingContext2D, eng: GameEngine): void {
const pts = eng.pathPx
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.lineWidth = TILE * 0.78
g.stroke()
g.strokeStyle = '#7a6142'
g.lineWidth = TILE * 0.7
g.stroke()
g.strokeStyle = '#c2a276'
g.lineWidth = TILE * 0.58
g.stroke()
// dashed center line
g.save()
g.strokeStyle = 'rgba(216,195,154,0.55)'
g.lineWidth = 3
g.setLineDash([12, 16])
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.stroke()
g.restore()
// pebbles on the path
const rng = mulberry32(777)
for (let i = 0; i < 90; i++) {
const seg = 1 + Math.floor(rng() * (pts.length - 1))
const a = pts[seg - 1]
const b = pts[seg]
const t = rng()
const off = (rng() - 0.5) * TILE * 0.45
const dx = b.x - a.x
const dy = b.y - a.y
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.beginPath()
g.arc(px, py, 1.2 + rng() * 1.6, 0, Math.PI * 2)
g.fill()
}
}
private drawDecor(g: CanvasRenderingContext2D, eng: GameEngine): void {
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)]
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
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.beginPath()
g.ellipse(x, y, 10 * s, 7 * s, 0, 0, Math.PI * 2)
g.fill()
g.fillStyle = '#4f8f45'
g.beginPath()
g.ellipse(x - 3 * s, y - 2 * s, 6 * s, 4.5 * s, 0, 0, Math.PI * 2)
g.fill()
} else if (d.type === 'rock') {
g.fillStyle = 'rgba(0,0,0,0.18)'
g.beginPath()
g.ellipse(x, y + 10 * s, 13 * s, 4 * s, 0, 0, Math.PI * 2)
g.fill()
g.fillStyle = '#8b9298'
g.beginPath()
g.moveTo(x - 12 * s, y + 8 * s)
g.lineTo(x - 9 * s, y - 7 * s)
g.lineTo(x + 2 * s, y - 11 * s)
g.lineTo(x + 12 * s, y - 2 * s)
g.lineTo(x + 10 * s, y + 8 * s)
g.closePath()
g.fill()
g.fillStyle = 'rgba(255,255,255,0.22)'
g.beginPath()
g.moveTo(x - 8 * s, y - 5 * s)
g.lineTo(x + 1 * s, y - 9 * s)
g.lineTo(x + 5 * s, y - 3 * s)
g.closePath()
g.fill()
} else {
// tree
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.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'
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(x - 4 * s, y - 16 * s, 6 * s, 0, Math.PI * 2)
g.fill()
}
}
}
// ------------------------------------------------------------ main render
render(eng: GameEngine): void {
const g = this.ctx
g.setTransform(this.pxScale, 0, 0, this.pxScale, 0, 0)
g.clearRect(0, 0, W, H)
g.save()
if (eng.shake > 0) {
const s = eng.shake * 5
g.translate((Math.random() - 0.5) * s, (Math.random() - 0.5) * s)
}
g.drawImage(this.bg, 0, 0, W, H)
this.drawPath(g, eng)
this.drawDecor(g, eng)
this.drawSpawn(g, eng)
this.drawBase(g, eng)
if (eng.buildType) this.drawBuildHints(g, eng)
for (const t of eng.towers) this.drawTower(g, eng, t, false)
// ground enemies first, then flying on top
for (const e of eng.enemies) if (!e.flying) this.drawEnemy(g, eng, e)
for (const p of eng.projectiles) this.drawProjectile(g, p.kind, p.x, p.y, p.angle)
for (const e of eng.enemies) if (e.flying) this.drawEnemy(g, eng, e)
this.drawEffects(g, eng)
this.drawBossBars(g, eng)
// hover ghost
if (eng.buildType && eng.hover && eng.hover.x >= 0 && eng.hover.x < W && eng.hover.y >= 0 && eng.hover.y < H) {
this.drawGhost(g, eng)
}
// selected tower highlight
const sel = eng.selectedTower()
if (sel) {
const def = TOWERS[sel.kind]
const range = def.levels[sel.level - 1].range
g.save()
g.strokeStyle = 'rgba(255,255,255,0.8)'
g.setLineDash([8, 6])
g.lineWidth = 2
g.beginPath()
g.arc(sel.x, sel.y, range, 0, Math.PI * 2)
g.stroke()
g.fillStyle = 'rgba(255,255,255,0.06)'
g.fill()
g.restore()
}
// selected obstacle highlight
const ob = eng.selectedObstacle
if (ob) {
g.save()
g.strokeStyle = 'rgba(255,255,255,0.85)'
g.setLineDash([8, 6])
g.lineWidth = 2
g.strokeRect(ob.tx * TILE + 3, ob.ty * TILE + 3, TILE - 6, TILE - 6)
g.restore()
}
g.restore()
}
private drawSpawn(g: CanvasRenderingContext2D, eng: GameEngine): void {
const p = eng.pathPx[0]
g.save()
g.translate(p.x + 18, p.y)
g.fillStyle = '#2a1e3d'
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.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.beginPath()
g.arc(0, 0, 3.5 + Math.sin(t * 3) * 1.2, 0, Math.PI * 2)
g.fill()
g.restore()
}
private drawBase(g: CanvasRenderingContext2D, eng: GameEngine): void {
const p = eng.pathPx[eng.pathPx.length - 1]
const x = p.x - 26
const y = p.y
g.save()
// shadow
g.fillStyle = 'rgba(0,0,0,0.25)'
g.beginPath()
g.ellipse(x, y + 26, 30, 8, 0, 0, Math.PI * 2)
g.fill()
// keep walls
g.fillStyle = '#8d8577'
g.fillRect(x - 24, y - 30, 48, 56)
g.fillStyle = '#a49b8b'
g.fillRect(x - 24, y - 30, 48, 8)
// crenellations
g.fillStyle = '#8d8577'
for (let i = 0; i < 4; i++) g.fillRect(x - 24 + i * 13, y - 38, 8, 8)
// gate
g.fillStyle = '#4c3a28'
g.beginPath()
g.moveTo(x - 10, y + 26)
g.lineTo(x - 10, y + 2)
g.arc(x, y + 2, 10, Math.PI, 0)
g.lineTo(x + 10, y + 26)
g.closePath()
g.fill()
// banner
g.strokeStyle = '#5b4a35'
g.lineWidth = 3
g.beginPath()
g.moveTo(x, y - 38)
g.lineTo(x, y - 62)
g.stroke()
const wave = Math.sin(eng.time * 4) * 3
g.fillStyle = '#d84b3f'
g.beginPath()
g.moveTo(x + 1, y - 62)
g.lineTo(x + 22 + wave, y - 56)
g.lineTo(x + 1, y - 48)
g.closePath()
g.fill()
// hearts = lives
g.fillStyle = '#fff'
g.font = 'bold 11px system-ui'
g.textAlign = 'center'
g.fillText('❤ ' + eng.lives, x, y - 12)
g.restore()
}
private drawBuildHints(g: CanvasRenderingContext2D, eng: GameEngine): void {
g.save()
g.strokeStyle = 'rgba(255,255,255,0.1)'
g.lineWidth = 1
for (let tx = 0; tx < 20; tx++) {
for (let ty = 0; ty < 11; ty++) {
const ok = eng.canPlace(tx, ty)
if (!ok) continue
g.strokeRect(tx * TILE + 3.5, ty * TILE + 3.5, TILE - 7, TILE - 7)
}
}
g.restore()
}
// ------------------------------------------------------------ towers
private drawTower(g: CanvasRenderingContext2D, eng: GameEngine, t: Tower, ghost: boolean): void {
const def = TOWERS[t.kind]
g.save()
if (ghost) g.globalAlpha = 0.55
g.translate(t.x, t.y)
// base plate
g.fillStyle = 'rgba(0,0,0,0.25)'
g.beginPath()
g.ellipse(2, 6, 19, 12, 0, 0, Math.PI * 2)
g.fill()
g.fillStyle = '#39404a'
this.roundRect(g, -18, -16, 36, 34, 7)
g.fill()
const baseGrad = g.createLinearGradient(0, -16, 0, 18)
baseGrad.addColorStop(0, def.color)
baseGrad.addColorStop(1, this.shade(def.color, -30))
g.fillStyle = baseGrad
this.roundRect(g, -16, -16, 32, 32, 6)
g.fill()
g.strokeStyle = 'rgba(0,0,0,0.35)'
g.lineWidth = 1.5
this.roundRect(g, -16, -16, 32, 32, 6)
g.stroke()
// multiplayer owner ring
if (t.owner !== null) {
g.strokeStyle = t.owner === 0 ? '#4da3ff' : '#ffb14d'
g.lineWidth = 2.5
g.beginPath()
g.arc(0, 2, 21, 0, Math.PI * 2)
g.stroke()
}
// level pips
for (let i = 0; i < t.level; i++) {
g.fillStyle = '#ffd23e'
g.beginPath()
g.arc(-8 + i * 8, 13, 2.6, 0, Math.PI * 2)
g.fill()
}
const recoil = t.recoil * 4
const time = eng.time
switch (t.kind) {
case 'arrow': {
g.rotate(t.angle)
g.translate(-recoil, 0)
// crossbow
g.fillStyle = '#5b4a35'
g.fillRect(-8, -3, 18, 6)
g.strokeStyle = '#e8c27a'
g.lineWidth = 2.5
g.beginPath()
g.moveTo(4, -10)
g.quadraticCurveTo(11, 0, 4, 10)
g.stroke()
g.strokeStyle = 'rgba(255,255,255,0.7)'
g.lineWidth = 1
g.beginPath()
g.moveTo(4, -10)
g.lineTo(-4, 0)
g.lineTo(4, 10)
g.stroke()
if (t.cooldown <= 0) {
g.strokeStyle = '#fff'
g.lineWidth = 1.5
g.beginPath()
g.moveTo(-4, 0)
g.lineTo(10, 0)
g.stroke()
}
break
}
case 'cannon': {
g.rotate(t.angle)
// barrel
g.fillStyle = '#2f353d'
this.roundRect(g, -6 - recoil, -5.5, 24, 11, 4)
g.fill()
g.fillStyle = '#454d57'
this.roundRect(g, -2 - recoil, -3.5, 18, 7, 3)
g.fill()
g.fillStyle = '#22262c'
g.beginPath()
g.arc(-6 - recoil, 0, 8, 0, Math.PI * 2)
g.fill()
g.fillStyle = def.accent
g.beginPath()
g.arc(-6 - recoil, 0, 3, 0, Math.PI * 2)
g.fill()
break
}
case 'frost': {
const pulse = 1 + Math.sin(time * 3) * 0.06 + t.pulse * 0.35
g.rotate(time * 0.6)
g.scale(pulse, pulse)
g.fillStyle = '#d8f4ff'
g.beginPath()
g.moveTo(0, -13)
g.lineTo(8, 0)
g.lineTo(0, 13)
g.lineTo(-8, 0)
g.closePath()
g.fill()
g.fillStyle = '#8fd8f0'
g.beginPath()
g.moveTo(0, -9)
g.lineTo(5.5, 0)
g.lineTo(0, 9)
g.lineTo(-5.5, 0)
g.closePath()
g.fill()
break
}
case 'tesla': {
g.fillStyle = '#3b3550'
g.fillRect(-3, -14, 6, 20)
const glow = 0.5 + Math.sin(time * 5) * 0.2
g.fillStyle = `rgba(200,180,255,${glow * 0.35})`
g.beginPath()
g.arc(0, -16, 12, 0, Math.PI * 2)
g.fill()
g.fillStyle = def.accent
g.beginPath()
g.arc(0, -16, 6.5, 0, Math.PI * 2)
g.fill()
g.fillStyle = '#fff'
g.beginPath()
g.arc(-1.5, -17.5, 2.2, 0, Math.PI * 2)
g.fill()
// idle sparks
if (Math.random() < 0.12) {
g.strokeStyle = 'rgba(200,180,255,0.8)'
g.lineWidth = 1.5
const a = Math.random() * Math.PI * 2
g.beginPath()
g.moveTo(Math.cos(a) * 7, -16 + Math.sin(a) * 7)
g.lineTo(Math.cos(a) * 12 + 3, -16 + Math.sin(a) * 12 + 2)
g.stroke()
}
break
}
case 'laser': {
g.rotate(t.angle)
const charge = t.cooldown <= 0 ? 1 : Math.max(0, 1 - t.cooldown * def.levels[t.level - 1].rate)
// dish
g.fillStyle = '#3a3f47'
this.roundRect(g, -10, -8, 14, 16, 4)
g.fill()
g.fillStyle = '#565e69'
this.roundRect(g, 0, -4, 14, 8, 3)
g.fill()
g.fillStyle = `rgba(255,138,122,${0.3 + charge * 0.7})`
g.beginPath()
g.arc(14, 0, 3 + charge * 1.5, 0, Math.PI * 2)
g.fill()
break
}
}
g.restore()
}
// ------------------------------------------------------------ enemies
private drawEnemy(g: CanvasRenderingContext2D, eng: GameEngine, e: Enemy): void {
const def = ENEMIES[e.kind]
const slowed = eng.time < e.slowUntil
// lane offset perpendicular to heading
const px = -Math.sin(e.heading) * e.laneOff
const py = Math.cos(e.heading) * e.laneOff
const x = e.x + px
const y = e.y + py
const bob = e.flying ? Math.sin(eng.time * 6 + e.wobble) * 3 - 12 : 0
g.save()
// shadow
g.fillStyle = 'rgba(0,0,0,0.28)'
g.beginPath()
g.ellipse(x, e.flying ? y + e.r + 6 : y + e.r * 0.7, e.r * 0.8, e.r * 0.3, 0, 0, Math.PI * 2)
g.fill()
g.translate(x, y + bob)
const squish = 1 + Math.sin(eng.time * 8 + e.wobble) * 0.07
const body = def.color
switch (e.kind) {
case 'normal': {
g.scale(1, squish)
g.fillStyle = body
g.beginPath()
g.arc(0, 0, e.r, 0, Math.PI * 2)
g.fill()
g.fillStyle = 'rgba(0,0,0,0.18)'
g.beginPath()
g.arc(0, e.r * 0.45, e.r * 0.8, 0, Math.PI)
g.fill()
this.eyes(g, e.r)
break
}
case 'fast': {
g.rotate(e.heading)
g.scale(1, squish)
g.fillStyle = body
g.beginPath()
g.moveTo(e.r + 3, 0)
g.lineTo(-e.r, -e.r * 0.8)
g.lineTo(-e.r * 0.4, 0)
g.lineTo(-e.r, e.r * 0.8)
g.closePath()
g.fill()
g.fillStyle = 'rgba(0,0,0,0.2)'
g.beginPath()
g.moveTo(e.r + 3, 0)
g.lineTo(-e.r * 0.4, 0)
g.lineTo(-e.r, e.r * 0.8)
g.closePath()
g.fill()
break
}
case 'tank': {
g.scale(1, 1)
// treads
g.fillStyle = '#3d434c'
this.roundRect(g, -e.r, -e.r - 2, e.r * 2, 6, 3)
g.fill()
this.roundRect(g, -e.r, e.r - 4, e.r * 2, 6, 3)
g.fill()
g.strokeStyle = 'rgba(255,255,255,0.25)'
g.lineWidth = 1.5
const treadOff = (e.traveled / 6) % 8
for (let i = 0; i < 4; i++) {
const tx = -e.r + 3 + ((i * 8 + treadOff) % (e.r * 2 - 4))
g.beginPath()
g.moveTo(tx, -e.r - 1)
g.lineTo(tx, -e.r + 3)
g.stroke()
g.beginPath()
g.moveTo(tx, e.r - 3)
g.lineTo(tx, e.r + 1)
g.stroke()
}
// hull
const hg = g.createLinearGradient(0, -e.r, 0, e.r)
hg.addColorStop(0, '#8f9aa6')
hg.addColorStop(1, '#68727d')
g.fillStyle = hg
this.roundRect(g, -e.r + 2, -e.r + 3, e.r * 2 - 4, e.r * 2 - 6, 4)
g.fill()
g.fillStyle = '#4c545e'
g.beginPath()
g.arc(0, 0, 5, 0, Math.PI * 2)
g.fill()
g.fillStyle = '#2f353d'
g.fillRect(0, -2, e.r + 2, 4)
break
}
case 'flyer': {
const flap = Math.sin(eng.time * 12 + e.wobble)
g.fillStyle = 'rgba(220,190,255,0.75)'
g.beginPath()
g.ellipse(-e.r * 0.9, -2, e.r * 0.85, 4 + flap * 3, -0.5 + flap * 0.25, 0, Math.PI * 2)
g.fill()
g.beginPath()
g.ellipse(e.r * 0.9, -2, e.r * 0.85, 4 + flap * 3, 0.5 - flap * 0.25, 0, Math.PI * 2)
g.fill()
g.fillStyle = body
g.beginPath()
g.ellipse(0, 0, e.r * 0.85, e.r, 0, 0, Math.PI * 2)
g.fill()
this.eyes(g, e.r * 0.9)
break
}
case 'boss': {
// spikes
g.fillStyle = '#8e2f27'
for (let i = 0; i < 10; i++) {
const a = (i / 10) * Math.PI * 2 + eng.time * 0.5
g.beginPath()
g.moveTo(Math.cos(a) * (e.r - 3), Math.sin(a) * (e.r - 3))
g.lineTo(Math.cos(a + 0.18) * (e.r + 7), Math.sin(a + 0.18) * (e.r + 7))
g.lineTo(Math.cos(a + 0.36) * (e.r - 3), Math.sin(a + 0.36) * (e.r - 3))
g.closePath()
g.fill()
}
const bg = g.createRadialGradient(-4, -4, 3, 0, 0, e.r)
bg.addColorStop(0, '#f07a5f')
bg.addColorStop(1, body)
g.fillStyle = bg
g.beginPath()
g.arc(0, 0, e.r, 0, Math.PI * 2)
g.fill()
// crown
g.fillStyle = '#ffd23e'
g.beginPath()
g.moveTo(-8, -e.r - 2)
g.lineTo(-8, -e.r - 10)
g.lineTo(-4, -e.r - 5)
g.lineTo(0, -e.r - 11)
g.lineTo(4, -e.r - 5)
g.lineTo(8, -e.r - 10)
g.lineTo(8, -e.r - 2)
g.closePath()
g.fill()
// angry eyes
g.fillStyle = '#fff'
g.beginPath()
g.arc(-5, -3, 3.4, 0, Math.PI * 2)
g.arc(5, -3, 3.4, 0, Math.PI * 2)
g.fill()
g.fillStyle = '#301010'
g.beginPath()
g.arc(-4.2, -2.5, 1.7, 0, Math.PI * 2)
g.arc(5.8, -2.5, 1.7, 0, Math.PI * 2)
g.fill()
break
}
}
// slow indicator
if (slowed) {
g.strokeStyle = 'rgba(140,220,255,0.9)'
g.lineWidth = 2
g.beginPath()
g.arc(0, 0, e.r + 3.5, 0, Math.PI * 2)
g.stroke()
g.fillStyle = 'rgba(160,230,255,0.25)'
g.beginPath()
g.arc(0, 0, e.r + 1, 0, Math.PI * 2)
g.fill()
}
// hit flash
if (e.flash > 0) {
g.fillStyle = `rgba(255,255,255,${e.flash * 0.6})`
g.beginPath()
g.arc(0, 0, e.r + 1, 0, Math.PI * 2)
g.fill()
}
g.restore()
// hp bar
const pct = Math.max(0, e.hp / e.maxHp)
if (pct < 1 || e.kind === 'boss') {
const bw = Math.max(20, e.r * 2.2)
const bx = x - bw / 2
const by = y + bob - e.r - (e.kind === 'boss' ? 16 : 9)
g.fillStyle = 'rgba(0,0,0,0.55)'
g.fillRect(bx - 1, by - 1, bw + 2, 5.5)
g.fillStyle = pct > 0.5 ? '#5cd74c' : pct > 0.25 ? '#ffd23e' : '#ff5f4e'
g.fillRect(bx, by, bw * pct, 3.5)
}
}
private eyes(g: CanvasRenderingContext2D, r: number): void {
g.fillStyle = '#fff'
g.beginPath()
g.arc(-r * 0.32, -r * 0.15, r * 0.3, 0, Math.PI * 2)
g.arc(r * 0.32, -r * 0.15, r * 0.3, 0, Math.PI * 2)
g.fill()
g.fillStyle = '#1c2026'
g.beginPath()
g.arc(-r * 0.28, -r * 0.1, r * 0.14, 0, Math.PI * 2)
g.arc(r * 0.36, -r * 0.1, r * 0.14, 0, Math.PI * 2)
g.fill()
}
// ------------------------------------------------------------ projectiles & effects
private drawProjectile(g: CanvasRenderingContext2D, kind: 'arrow' | 'ball', x: number, y: number, angle: number): void {
g.save()
g.translate(x, y)
g.rotate(angle)
if (kind === 'arrow') {
g.strokeStyle = '#d8b56a'
g.lineWidth = 2.5
g.beginPath()
g.moveTo(-7, 0)
g.lineTo(5, 0)
g.stroke()
g.fillStyle = '#fff'
g.beginPath()
g.moveTo(8, 0)
g.lineTo(3, -3)
g.lineTo(3, 3)
g.closePath()
g.fill()
} else {
g.fillStyle = '#26292e'
g.beginPath()
g.arc(0, 0, 5, 0, Math.PI * 2)
g.fill()
g.fillStyle = 'rgba(255,255,255,0.35)'
g.beginPath()
g.arc(-1.5, -1.5, 1.8, 0, Math.PI * 2)
g.fill()
}
g.restore()
}
private drawEffects(g: CanvasRenderingContext2D, eng: GameEngine): void {
for (const fx of eng.effects) {
const p = fx.life / fx.max
switch (fx.type) {
case 'particle': {
g.globalAlpha = Math.min(1, p * 1.6)
g.fillStyle = fx.color
g.beginPath()
g.arc(fx.x, fx.y, fx.size * (0.4 + p * 0.6), 0, Math.PI * 2)
g.fill()
g.globalAlpha = 1
break
}
case 'ring': {
const r = fx.r0 + (fx.r1 - fx.r0) * (1 - p)
g.globalAlpha = p
g.strokeStyle = fx.color
g.lineWidth = fx.width
g.beginPath()
g.arc(fx.x, fx.y, r, 0, Math.PI * 2)
g.stroke()
g.globalAlpha = 1
break
}
case 'beam': {
g.save()
g.globalAlpha = p
g.globalCompositeOperation = 'lighter'
g.strokeStyle = fx.color
g.lineWidth = 7 * p + 1
g.beginPath()
g.moveTo(fx.x1, fx.y1)
g.lineTo(fx.x2, fx.y2)
g.stroke()
g.strokeStyle = '#fff'
g.lineWidth = 2.5
g.beginPath()
g.moveTo(fx.x1, fx.y1)
g.lineTo(fx.x2, fx.y2)
g.stroke()
g.restore()
break
}
case 'bolt': {
g.save()
g.globalAlpha = p
g.globalCompositeOperation = 'lighter'
for (let pass = 0; pass < 2; pass++) {
g.strokeStyle = pass === 0 ? fx.color : '#fff'
g.lineWidth = pass === 0 ? 4 : 1.8
g.beginPath()
g.moveTo(fx.pts[0].x, fx.pts[0].y)
for (let i = 1; i < fx.pts.length; i++) {
const a = fx.pts[i - 1]
const b = fx.pts[i]
const mx = (a.x + b.x) / 2 + (Math.random() - 0.5) * 14
const my = (a.y + b.y) / 2 + (Math.random() - 0.5) * 14
g.lineTo(mx, my)
g.lineTo(b.x, b.y)
}
g.stroke()
}
g.restore()
break
}
case 'text': {
g.globalAlpha = Math.min(1, p * 2)
g.fillStyle = fx.color
g.font = `bold ${fx.size}px system-ui`
g.textAlign = 'center'
g.strokeStyle = 'rgba(0,0,0,0.6)'
g.lineWidth = 3
g.strokeText(fx.str, fx.x, fx.y)
g.fillText(fx.str, fx.x, fx.y)
g.globalAlpha = 1
break
}
case 'announce': {
const inP = Math.min(1, (1 - p) * 5)
const y = H / 2 - 30
g.globalAlpha = Math.min(1, p * 2.5)
g.textAlign = 'center'
g.font = `bold ${Math.round(30 + (1 - inP) * 20)}px system-ui`
g.strokeStyle = 'rgba(0,0,0,0.75)'
g.lineWidth = 6
g.strokeText(fx.str, W / 2, y)
g.fillStyle = '#fff'
g.fillText(fx.str, W / 2, y)
g.font = 'bold 15px system-ui'
g.strokeText(fx.sub, W / 2, y + 26)
g.fillStyle = '#ffd23e'
g.fillText(fx.sub, W / 2, y + 26)
g.globalAlpha = 1
break
}
}
}
}
private drawBossBars(g: CanvasRenderingContext2D, eng: GameEngine): void {
const bosses = eng.enemies.filter((e) => e.kind === 'boss' && !e.dead)
if (bosses.length === 0) return
const bw = 300
for (let i = 0; i < bosses.length; i++) {
const e = bosses[i]
const pct = Math.max(0, e.hp / e.maxHp)
const x = W / 2 - bw / 2
const y = 14 + i * 20
g.fillStyle = 'rgba(0,0,0,0.6)'
this.roundRect(g, x - 2, y - 2, bw + 4, 14, 6)
g.fill()
g.fillStyle = '#7a2620'
this.roundRect(g, x, y, bw, 10, 4)
g.fill()
g.fillStyle = '#e04b3a'
this.roundRect(g, x, y, bw * pct, 10, 4)
g.fill()
g.fillStyle = '#fff'
g.font = 'bold 10px system-ui'
g.textAlign = 'center'
g.fillText(`BOSS ${Math.ceil(e.hp)}`, W / 2, y + 8)
}
}
// ------------------------------------------------------------ ghost
private drawGhost(g: CanvasRenderingContext2D, eng: GameEngine): void {
if (!eng.hover || !eng.buildType) return
const def = TOWERS[eng.buildType]
const tx = eng.hover.tx
const ty = eng.hover.ty
const cx = tx * TILE + TILE / 2
const cy = ty * TILE + TILE / 2
const valid = eng.canPlace(tx, ty) && eng.money >= def.cost
const range = def.levels[0].range
g.save()
g.fillStyle = valid ? 'rgba(90,220,120,0.1)' : 'rgba(255,80,60,0.12)'
g.strokeStyle = valid ? 'rgba(90,220,120,0.7)' : 'rgba(255,80,60,0.8)'
g.lineWidth = 2
g.setLineDash([8, 6])
g.beginPath()
g.arc(cx, cy, range, 0, Math.PI * 2)
g.stroke()
g.fill()
g.setLineDash([])
g.strokeStyle = valid ? 'rgba(255,255,255,0.9)' : 'rgba(255,80,60,0.9)'
g.strokeRect(tx * TILE + 2, ty * TILE + 2, TILE - 4, TILE - 4)
if (valid) {
this.drawTower(
g,
eng,
{
id: -1,
kind: eng.buildType,
tx,
ty,
x: cx,
y: cy,
level: 1,
cooldown: 0,
angle: -Math.PI / 2,
kills: 0,
damageDealt: 0,
targeting: 'first',
invested: 0,
recoil: 0,
pulse: 0,
targetId: null,
owner: null,
},
true,
)
} else {
g.strokeStyle = 'rgba(255,80,60,0.95)'
g.lineWidth = 4
g.beginPath()
g.moveTo(cx - 10, cy - 10)
g.lineTo(cx + 10, cy + 10)
g.moveTo(cx + 10, cy - 10)
g.lineTo(cx - 10, cy + 10)
g.stroke()
}
g.restore()
}
// ------------------------------------------------------------ helpers
private roundRect(g: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
g.beginPath()
g.moveTo(x + r, y)
g.arcTo(x + w, y, x + w, y + h, r)
g.arcTo(x + w, y + h, x, y + h, r)
g.arcTo(x, y + h, x, y, r)
g.arcTo(x, y, x + w, y, r)
g.closePath()
}
private shade(hex: string, amt: number): string {
const n = parseInt(hex.slice(1), 16)
const r = Math.max(0, Math.min(255, (n >> 16) + amt))
const gg = Math.max(0, Math.min(255, ((n >> 8) & 0xff) + amt))
const b = Math.max(0, Math.min(255, (n & 0xff) + amt))
return `rgb(${r},${gg},${b})`
}
}

171
src/game/sound.ts Normal file
View file

@ -0,0 +1,171 @@
/**
* Tiny WebAudio synth all sound effects are generated, no audio assets needed.
*/
export type SfxName =
| 'shootArrow'
| 'shootCannon'
| 'pulse'
| 'tesla'
| 'laser'
| 'death'
| 'build'
| 'upgrade'
| 'sell'
| 'leak'
| 'wave'
| 'victory'
| 'defeat'
| 'click'
| 'error'
class SoundManager {
private ctx: AudioContext | null = null
private master: GainNode | null = null
private lastPlay = new Map<SfxName, number>()
muted = false
constructor() {
if (typeof localStorage !== 'undefined') {
this.muted = localStorage.getItem('trxtd-muted') === '1'
}
}
/** must be called from a user gesture at least once */
ensure(): void {
if (typeof AudioContext === 'undefined') return
if (!this.ctx) {
try {
this.ctx = new AudioContext()
this.master = this.ctx.createGain()
this.master.gain.value = 0.4
this.master.connect(this.ctx.destination)
} catch {
this.ctx = null
}
}
if (this.ctx && this.ctx.state === 'suspended') void this.ctx.resume()
}
setMuted(m: boolean): void {
this.muted = m
if (typeof localStorage !== 'undefined') {
localStorage.setItem('trxtd-muted', m ? '1' : '0')
}
}
private tone(
freq: number,
dur: number,
type: OscillatorType,
vol: number,
slideTo?: number,
delay = 0,
): void {
if (!this.ctx || !this.master) return
const t0 = this.ctx.currentTime + delay
const osc = this.ctx.createOscillator()
const gain = this.ctx.createGain()
osc.type = type
osc.frequency.setValueAtTime(freq, t0)
if (slideTo !== undefined) osc.frequency.exponentialRampToValueAtTime(Math.max(20, slideTo), t0 + dur)
gain.gain.setValueAtTime(vol, t0)
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + dur)
osc.connect(gain)
gain.connect(this.master)
osc.start(t0)
osc.stop(t0 + dur + 0.02)
}
private noise(dur: number, vol: number, filterFreq: number, delay = 0): void {
if (!this.ctx || !this.master) return
const t0 = this.ctx.currentTime + delay
const len = Math.max(1, Math.floor(this.ctx.sampleRate * dur))
const buf = this.ctx.createBuffer(1, len, this.ctx.sampleRate)
const data = buf.getChannelData(0)
for (let i = 0; i < len; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / len)
const src = this.ctx.createBufferSource()
src.buffer = buf
const filter = this.ctx.createBiquadFilter()
filter.type = 'lowpass'
filter.frequency.value = filterFreq
const gain = this.ctx.createGain()
gain.gain.value = vol
src.connect(filter)
filter.connect(gain)
gain.connect(this.master)
src.start(t0)
}
play(name: SfxName): void {
if (this.muted || !this.ctx) return
// rate limit identical sounds (avoids buzzing at 3x speed)
const now = this.ctx.currentTime
const last = this.lastPlay.get(name) ?? -1
if (now - last < 0.035) return
this.lastPlay.set(name, now)
switch (name) {
case 'shootArrow':
this.tone(660, 0.06, 'square', 0.06, 880)
break
case 'shootCannon':
this.noise(0.18, 0.5, 900)
this.tone(110, 0.16, 'triangle', 0.25, 55)
break
case 'pulse':
this.tone(950, 0.22, 'sine', 0.1, 380)
break
case 'tesla':
this.noise(0.08, 0.25, 6000)
this.tone(520, 0.1, 'square', 0.06, 120)
break
case 'laser':
this.tone(1250, 0.2, 'sawtooth', 0.12, 180)
break
case 'death':
this.tone(320, 0.14, 'triangle', 0.14, 90)
break
case 'build':
this.tone(420, 0.07, 'square', 0.12)
this.tone(640, 0.09, 'square', 0.12, undefined, 0.07)
break
case 'upgrade':
this.tone(440, 0.09, 'square', 0.1)
this.tone(560, 0.09, 'square', 0.1, undefined, 0.08)
this.tone(720, 0.12, 'square', 0.1, undefined, 0.16)
break
case 'sell':
this.tone(600, 0.08, 'triangle', 0.12, 320)
this.tone(380, 0.1, 'triangle', 0.1, 200, 0.08)
break
case 'leak':
this.tone(190, 0.16, 'sawtooth', 0.2)
this.tone(140, 0.22, 'sawtooth', 0.2, undefined, 0.16)
break
case 'wave':
this.tone(330, 0.1, 'square', 0.1)
this.tone(392, 0.1, 'square', 0.1, undefined, 0.1)
this.tone(494, 0.16, 'square', 0.12, undefined, 0.2)
break
case 'victory':
this.tone(523, 0.14, 'square', 0.12)
this.tone(659, 0.14, 'square', 0.12, undefined, 0.14)
this.tone(784, 0.14, 'square', 0.12, undefined, 0.28)
this.tone(1047, 0.3, 'square', 0.14, undefined, 0.42)
break
case 'defeat':
this.tone(400, 0.25, 'sawtooth', 0.14, 300)
this.tone(300, 0.3, 'sawtooth', 0.14, 200, 0.25)
this.tone(200, 0.5, 'sawtooth', 0.14, 90, 0.5)
break
case 'click':
this.tone(800, 0.04, 'square', 0.05)
break
case 'error':
this.tone(160, 0.12, 'square', 0.1, 120)
break
}
}
}
export const sound = new SoundManager()

67
src/game/store.ts Normal file
View file

@ -0,0 +1,67 @@
import { reactive } from 'vue'
import type { DifficultyId, EnemyKind, MPMode, Phase, Screen, SelectedTowerInfo, TowerKind } from './types'
/**
* Reactive bridge between the (non-reactive) game engine and the Vue UI.
* The engine writes, the components read.
*/
export const store = reactive({
screen: 'menu' as Screen,
paused: false,
speed: 1,
muted: false,
difficulty: 'normal' as DifficultyId,
money: 0,
lives: 0,
maxLives: 0,
waveNo: 0,
totalWaves: 20,
endless: false,
phase: 'idle' as Phase,
nextWaveIn: 0,
waveActive: false,
score: 0,
kills: 0,
buildType: null as TowerKind | null,
hoverValid: false,
selected: null as SelectedTowerInfo | null,
obstacle: null as { tx: number; ty: number; type: 'tree' | 'rock'; cost: number } | null,
/** composition of the upcoming wave, for the preview chips */
nextPreview: [] as { kind: EnemyKind; count: number }[],
result: null as { win: boolean; score: number; wave: number; kills: number; best: number; bestBefore: number } | null,
/** multiplayer state (lobby + in-game info) */
mp: {
active: false,
mode: 'coop' as MPMode,
status: '',
name: '',
peerName: '',
roomCode: '',
players: [] as { id: number; name: string }[],
myId: 0,
isHost: false,
inLobby: false,
// duel: live stats of the opponent board
opponentLives: 0,
opponentGold: 0,
opponentWave: 0,
rushCost: 0,
resultMsg: '',
},
})
export function bestScoreKey(diff: DifficultyId): string {
return `trxtd-best-${diff}`
}
export function loadBest(diff: DifficultyId): number {
if (typeof localStorage === 'undefined') return 0
return Number(localStorage.getItem(bestScoreKey(diff)) ?? 0)
}

181
src/game/types.ts Normal file
View file

@ -0,0 +1,181 @@
export type TowerKind = 'arrow' | 'cannon' | 'frost' | 'tesla' | 'laser'
export type EnemyKind = 'normal' | 'fast' | 'tank' | 'flyer' | 'boss'
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 MPMode = 'coop' | 'duel'
/** synchronized player actions (lockstep); applied identically on all clients */
export type MPAction =
| { type: 'build'; kind: TowerKind; tx: number; ty: number }
| { type: 'upgrade'; towerId: number }
| { type: 'sell'; towerId: number }
| { type: 'targeting'; towerId: number; mode: TargetingMode }
| { type: 'obstacle'; tx: number; ty: number }
| { type: 'wave' }
| { type: 'speed'; s: number }
| { type: 'rush' }
export interface Pt {
x: number
y: number
}
export interface EnemyDef {
kind: EnemyKind
name: string
hp: number
speed: number
reward: number
radius: number
dmg: number
flying: boolean
color: string
}
export interface TowerLevelStats {
damage: number
range: number
/** shots per second */
rate: number
}
export interface TowerDef {
kind: TowerKind
name: string
icon: string
desc: string
cost: number
/** upgrade cost to level 2 and 3 */
upgradeCost: [number, number]
levels: [TowerLevelStats, TowerLevelStats, TowerLevelStats]
hitsFlying: boolean
projectileSpeed?: number
/** splash radius per level (cannon) */
splash?: [number, number, number]
/** speed multiplier applied while slowed, per level (frost) */
slowFactor?: [number, number, number]
slowDuration?: [number, number, number]
/** chain lightning targets per level (tesla) */
chain?: [number, number, number]
chainRange?: number
color: string
accent: string
hotkey: string
}
export interface Enemy {
id: number
kind: EnemyKind
flying: boolean
x: number
y: number
/** index of the waypoint this enemy is moving towards */
wp: number
hp: number
maxHp: number
speedBase: number
reward: number
dmg: number
r: number
traveled: number
slowUntil: number
slowFactor: number
dead: boolean
escaped: boolean
flash: number
wobble: number
laneOff: number
heading: number
}
export interface Tower {
id: number
kind: TowerKind
tx: number
ty: number
x: number
y: number
level: 1 | 2 | 3
cooldown: number
angle: number
kills: number
damageDealt: number
targeting: TargetingMode
invested: number
recoil: number
pulse: number
targetId: number | null
/** multiplayer: which player built this tower (null in solo) */
owner: number | null
}
export interface Projectile {
kind: 'arrow' | 'ball'
x: number
y: number
angle: number
speed: number
damage: number
splash: number
target: Enemy | null
tx: number
ty: number
life: number
trail: number
ownerId: number
}
export type Effect =
| { type: 'particle'; x: number; y: number; vx: number; vy: number; life: number; max: number; size: number; color: string; grav: number }
| { type: 'ring'; x: number; y: number; r0: number; r1: number; life: number; max: number; color: string; width: number }
| { type: 'beam'; x1: number; y1: number; x2: number; y2: number; life: number; max: number; color: string }
| { type: 'bolt'; pts: Pt[]; life: number; max: number; color: string }
| { type: 'text'; x: number; y: number; vy: number; life: number; max: number; str: string; color: string; size: number }
| { type: 'announce'; str: string; sub: string; life: number; max: number }
export interface SelectedTowerInfo {
id: number
kind: TowerKind
name: string
icon: string
desc: string
level: number
maxLevel: boolean
damage: number
range: number
rate: number
dps: number
upgradeCost: number | null
sellValue: number
targeting: TargetingMode
kills: number
damageDealt: number
hitsFlying: boolean
special: string
}
export interface WaveGroup {
kind: EnemyKind
count: number
/** seconds between spawns */
interval: number
}
export interface DifficultyDef {
id: DifficultyId
name: string
desc: string
lives: number
money: number
hpMul: number
}
export interface DecorItem {
type: 'tree' | 'rock' | 'flower' | 'bush'
x: number
y: number
s: number
seed: number
}

46
src/game/utils.ts Normal file
View file

@ -0,0 +1,46 @@
export function clamp(v: number, lo: number, hi: number): number {
return v < lo ? lo : v > hi ? hi : v
}
export function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t
}
export function dist(ax: number, ay: number, bx: number, by: number): number {
return Math.hypot(bx - ax, by - ay)
}
export function angleTo(ax: number, ay: number, bx: number, by: number): number {
return Math.atan2(by - ay, bx - ax)
}
/** shortest-path angle interpolation */
export function lerpAngle(a: number, b: number, t: number): number {
let d = b - a
while (d > Math.PI) d -= Math.PI * 2
while (d < -Math.PI) d += Math.PI * 2
return a + d * t
}
export function angleDiff(a: number, b: number): number {
let d = b - a
while (d > Math.PI) d -= Math.PI * 2
while (d < -Math.PI) d += Math.PI * 2
return Math.abs(d)
}
/** deterministic RNG (mulberry32) */
export function mulberry32(seed: number): () => number {
let a = seed >>> 0
return () => {
a |= 0
a = (a + 0x6d2b79f5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
export function cellKey(tx: number, ty: number): string {
return tx + ',' + ty
}

5
src/main.ts Normal file
View file

@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')

59
src/style.css Normal file
View file

@ -0,0 +1,59 @@
:root {
--bg: #0e131a;
--panel: #161d27;
--panel-inset: #0e141c;
--panel-border: #263140;
--text: #e8edf4;
--text-dim: #93a1b3;
--accent: #f5c542;
--accent-dim: #8a7434;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
body {
background:
radial-gradient(1200px 500px at 50% -100px, rgba(64, 110, 59, 0.25), transparent),
var(--bg);
color: var(--text);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
min-height: 100vh;
}
#app {
min-height: 100vh;
}
button {
font-family: inherit;
}
.btn {
background: var(--panel-inset);
border: 1px solid var(--panel-border);
color: var(--text);
font-weight: 700;
border-radius: 9px;
padding: 8px 12px;
cursor: pointer;
transition: background 0.12s ease, border-color 0.12s ease;
}
.btn:hover {
border-color: var(--accent-dim);
}
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(224, 86, 63, 0.5); }
50% { box-shadow: 0 0 0 8px rgba(224, 86, 63, 0); }
}
.pulse {
animation: pulse 1.6s infinite;
}