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

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>