The campaign was too easy: a handful of un-upgraded towers stopped everything. This makes the game substantially harder across three axes. Campaign extended from 20 to 30 hand-tuned waves (endless now starts at 31). Victory messages, HUD label (via store.totalWaves synced from TOTAL_WAVES), start screen, lobby and README updated. Five new enemy types debut in later waves, each with a hand-drawn canvas look: - Brute (wave 8): fast tank hybrid, 130 HP, 2 lives - Phantom (wave 12): fast flyer with real HP, 2 lives - Scorpion (wave 14): very fast ground, 2 lives - Golem (wave 18): walking bunker, 560 HP, 3 lives - Dragon (wave 22): flying boss, 950 HP, 3 lives, gets its own top boss health bar like the boss Per-wave HP scaling steepened (1 + 0.16m + 0.025m², was 0.18m + 0.02m²): wave 10 now ~4.6x (was 4.2x), wave 20 ~13.2x (was 11.6x), wave 30 ~29x. Late waves mix the new types into heavy compositions; wave 30 is the final wall (3 bosses, 2 dragons, 4 golems, 12 scorpions). Endless waves scale all ten types with bosses every 5 and dragons from endless+2. Balance validated with the headless greedy-bot simulation: the bot (capped around tower level 5 on fixed spots) previously trivially won the campaign and now dies at wave 29 — clearing wave 30 requires evolved towers (level 6+), research bonuses and good placement. The sim docs were updated to describe this new tuning philosophy. New render smoke test (npm test): draws all ten enemy kinds through the real renderer with a stub 2D context, two frames each with slow/ DoT/flash effects active, guarding every drawEnemy code path. 55 checks green, build clean. Browser-verified: campaign starts and HUD shows "Welle x/30".
148 lines
3.7 KiB
Vue
148 lines
3.7 KiB
Vue
<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, store.mapId)
|
|
}
|
|
|
|
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 30 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>Verdient</span><b class="crystal">+{{ store.result.crystalsEarned }} 💎</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;
|
|
}
|
|
.stat b.crystal {
|
|
color: #7fd8ff;
|
|
}
|
|
.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>
|