feat: initialize TRXTD browser tower defense
This commit is contained in:
commit
c4347f8420
34 changed files with 8302 additions and 0 deletions
60
src/App.vue
Normal file
60
src/App.vue
Normal 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>
|
||||
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>
|
||||
303
src/game/config.ts
Normal file
303
src/game/config.ts
Normal 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
1293
src/game/engine.ts
Normal file
File diff suppressed because it is too large
Load diff
404
src/game/mpgame.ts
Normal file
404
src/game/mpgame.ts
Normal 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
157
src/game/net.ts
Normal 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
993
src/game/render.ts
Normal 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
171
src/game/sound.ts
Normal 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
67
src/game/store.ts
Normal 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
181
src/game/types.ts
Normal 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
46
src/game/utils.ts
Normal 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
5
src/main.ts
Normal 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
59
src/style.css
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue