feat: initialize TRXTD browser tower defense
This commit is contained in:
commit
c4347f8420
34 changed files with 8302 additions and 0 deletions
144
src/components/EndOverlay.vue
Normal file
144
src/components/EndOverlay.vue
Normal 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>
|
||||
169
src/components/GameCanvas.vue
Normal file
169
src/components/GameCanvas.vue
Normal 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
222
src/components/Hud.vue
Normal 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>
|
||||
220
src/components/LobbyScreen.vue
Normal file
220
src/components/LobbyScreen.vue
Normal 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
84
src/components/MPBar.vue
Normal 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 & 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>
|
||||
102
src/components/ObstaclePanel.vue
Normal file
102
src/components/ObstaclePanel.vue
Normal 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>
|
||||
88
src/components/PauseOverlay.vue
Normal file
88
src/components/PauseOverlay.vue
Normal 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>1–5</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 & 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>
|
||||
70
src/components/PiPCanvas.vue
Normal file
70
src/components/PiPCanvas.vue
Normal 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>
|
||||
332
src/components/StartScreen.vue
Normal file
332
src/components/StartScreen.vue
Normal 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>1–5</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>
|
||||
208
src/components/TowerPanel.vue
Normal file
208
src/components/TowerPanel.vue
Normal 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>
|
||||
98
src/components/TowerShop.vue
Normal file
98
src/components/TowerShop.vue
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue