feat(balance): 30-wave campaign, 5 new enemy types, steeper per-wave scaling
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".
This commit is contained in:
parent
f7ab56e475
commit
aa68343ac2
13 changed files with 402 additions and 38 deletions
|
|
@ -152,11 +152,18 @@ Türme ab Level 4 erhalten im Spiel eine pulsierende **Evolutions-Aura** (orange
|
||||||
|
|
||||||
## Gegner
|
## Gegner
|
||||||
|
|
||||||
|
**Kampagne: 30 Wellen** (danach Endlos). Die Gegnerstärke skaliert pro Welle quadratisch — späte Wellen brauchen evolvierte Türme (Level 6+) und Forschung.
|
||||||
|
|
||||||
- **🟢 Kriecher:** Standard-Einheit, gut für frühes Gold
|
- **🟢 Kriecher:** Standard-Einheit, gut für frühes Gold
|
||||||
- **🟡 Läufer:** Sehr schnell – Bogentürme oder Eisturm nötig
|
- **🟡 Läufer:** Sehr schnell – Bogentürme oder Eisturm nötig
|
||||||
- **⬜ Panzer:** Zäh, kostet **2 Leben** bei Durchbruch
|
- **⬜ Panzer:** Zäh, kostet **2 Leben** bei Durchbruch
|
||||||
- **🟣 Flieger:** Ignorieren den Weg und fliegen auf halber Höhe geradeaus (nur Bogenturm, Eisturm, Tesla, Laser!)
|
- **🟣 Flieger:** Ignorieren den Weg und fliegen auf halber Höhe geradeaus (nur Bogenturm, Eisturm, Tesla, Laser!)
|
||||||
- **🔴 Boss:** Riesige Lebensleiste, kostet **5 Leben** (Wellen 10, 15, 20 …)
|
- **🔴 Boss:** Riesige Lebensleiste, kostet **5 Leben** (Wellen 10, 15, 20, 25, 30)
|
||||||
|
- **🟤 Brute** *(ab Welle 8):* Schneller Panzer — zäh UND flott, kostet 2 Leben
|
||||||
|
- **⚪ Phantom** *(ab Welle 12):* Schneller Flieger mit viel Leben — Luftverteidigung Pflicht
|
||||||
|
- **🦂 Skorpion** *(ab Welle 14):* Extrem schnell am Boden, kostet 2 Leben
|
||||||
|
- **🗿 Golem** *(ab Welle 18):* Laufender Bunker, riesige Lebensleiste, kostet 3 Leben
|
||||||
|
- **🐲 Drache** *(ab Welle 22):* Fliegender Boss, kostet 3 Leben — eigene Boss-Leiste am oberen Rand
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
"build": "vue-tsc --noEmit && vite build",
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"sim": "tsx scripts/sim.mts",
|
"sim": "tsx scripts/sim.mts",
|
||||||
"test": "node scripts/test-db.mjs && node scripts/test-auth.mjs"
|
"test": "node scripts/test-db.mjs && node scripts/test-auth.mjs && tsx scripts/test-render-smoke.mts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
/**
|
/**
|
||||||
* Headless balance simulation: a greedy bot plays the game on 'normal'
|
* Headless balance simulation: a greedy bot plays the campaign on 'normal'
|
||||||
* to verify the campaign is winnable (and not trivially easy).
|
* as a difficulty baseline. The bot uses predetermined build spots and a
|
||||||
|
* naive greedy upgrade policy (it plateaus around tower level 5), so where
|
||||||
|
* it dies marks the difficulty wall for un-evolved towers. The 30-wave
|
||||||
|
* campaign is tuned so that clearing it requires evolved towers (level 6+),
|
||||||
|
* research bonuses and good placement – the bot dying late (wave ~28+) is
|
||||||
|
* expected and healthy; dying before ~wave 15 would mean the mid-game is
|
||||||
|
* overtuned.
|
||||||
*
|
*
|
||||||
* Run: npm run sim
|
* Run: npm run sim
|
||||||
*/
|
*/
|
||||||
|
|
@ -8,8 +14,8 @@ import { GameEngine } from '../src/game/engine.ts'
|
||||||
import { TOWERS } from '../src/game/config.ts'
|
import { TOWERS } from '../src/game/config.ts'
|
||||||
import type { TowerKind } from '../src/game/types.ts'
|
import type { TowerKind } from '../src/game/types.ts'
|
||||||
|
|
||||||
/** campaign balance stays tuned for 3 levels – evolutions are endless-mode luxury */
|
/** upper tower level bound for the greedy bot (it rarely reaches it) */
|
||||||
const SIM_MAX_LEVEL = 3
|
const SIM_MAX_LEVEL = 9
|
||||||
|
|
||||||
const eng = new GameEngine()
|
const eng = new GameEngine()
|
||||||
eng.startGame('normal')
|
eng.startGame('normal')
|
||||||
|
|
@ -153,7 +159,7 @@ while (eng.phase !== 'gameover' && eng.phase !== 'victory' && guard < 60 * 60 *
|
||||||
console.log('---')
|
console.log('---')
|
||||||
console.log(`Ergebnis: ${eng.phase === 'victory' ? 'SIEG' : 'NIEDERLAGE'} in Welle ${eng.waveNo}, Leben übrig: ${eng.lives}, Punkte: ${eng.score}`)
|
console.log(`Ergebnis: ${eng.phase === 'victory' ? 'SIEG' : 'NIEDERLAGE'} in Welle ${eng.waveNo}, Leben übrig: ${eng.lives}, Punkte: ${eng.score}`)
|
||||||
if (eng.phase !== 'victory') {
|
if (eng.phase !== 'victory') {
|
||||||
console.log(' → Balancing zu schwer! Gegner-Werte abschwächen.')
|
console.log(' → Greedy-Baseline gescheitert. Tod vor ~Welle 15 wäre zu schwer, spät (~28+) ist Ziel.')
|
||||||
} else if (eng.lives > 14) {
|
} else if (eng.lives > 14) {
|
||||||
console.log(' → Balancing zu leicht (zu viele Leben übrig).')
|
console.log(' → Balancing zu leicht (zu viele Leben übrig).')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
71
scripts/test-render-smoke.mts
Normal file
71
scripts/test-render-smoke.mts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
/**
|
||||||
|
* Render smoke test: runs the canvas renderer with a stub 2D context and
|
||||||
|
* one enemy of every kind on screen, so every drawEnemy code path
|
||||||
|
* (including new enemy types) executes without throwing.
|
||||||
|
* Run: npx tsx scripts/test-render-smoke.mts
|
||||||
|
*/
|
||||||
|
import { GameEngine } from '../src/game/engine.ts'
|
||||||
|
import type { EnemyKind } from '../src/game/types.ts'
|
||||||
|
|
||||||
|
const gradient = { addColorStop: () => undefined }
|
||||||
|
const noop = () => undefined
|
||||||
|
|
||||||
|
const makeCanvas = (): Record<string, unknown> => ({
|
||||||
|
width: 960,
|
||||||
|
height: 528,
|
||||||
|
style: {},
|
||||||
|
getContext: () => makeCtx(),
|
||||||
|
addEventListener: noop,
|
||||||
|
removeEventListener: noop,
|
||||||
|
})
|
||||||
|
|
||||||
|
const makeCtx = (): Record<string, unknown> => ({
|
||||||
|
canvas: {},
|
||||||
|
createLinearGradient: () => gradient,
|
||||||
|
createRadialGradient: () => gradient,
|
||||||
|
measureText: () => ({ width: 10 }),
|
||||||
|
addColorStop: noop, arc: noop, arcTo: noop, beginPath: noop, clearRect: noop,
|
||||||
|
closePath: noop, drawImage: noop, ellipse: noop, fill: noop, fillRect: noop,
|
||||||
|
fillText: noop, lineTo: noop, moveTo: noop, quadraticCurveTo: noop,
|
||||||
|
bezierCurveTo: noop, rect: noop, restore: noop, rotate: noop, roundRect: noop,
|
||||||
|
save: noop, scale: noop, setLineDash: noop, setTransform: noop, stroke: noop,
|
||||||
|
strokeRect: noop, strokeText: noop, translate: noop, clip: noop,
|
||||||
|
})
|
||||||
|
|
||||||
|
// the renderer creates an offscreen background canvas via document
|
||||||
|
;(globalThis as Record<string, unknown>).document = {
|
||||||
|
createElement: () => makeCanvas(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const fakeCanvas = makeCanvas() as unknown as HTMLCanvasElement
|
||||||
|
|
||||||
|
const eng = new GameEngine()
|
||||||
|
eng.startGame('normal', 'meadow')
|
||||||
|
;(eng as unknown as { attach(c: HTMLCanvasElement): void }).attach(fakeCanvas)
|
||||||
|
|
||||||
|
const kinds: EnemyKind[] = ['normal', 'fast', 'tank', 'flyer', 'boss', 'brute', 'phantom', 'scorpion', 'golem', 'dragon']
|
||||||
|
const engAny = eng as unknown as { spawnEnemy(k: EnemyKind): void }
|
||||||
|
kinds.forEach((k) => engAny.spawnEnemy(k))
|
||||||
|
|
||||||
|
// spread enemies along the path so overlays, hp bars & effects also draw
|
||||||
|
eng.enemies.forEach((e, i) => {
|
||||||
|
e.x = 120 + i * 70
|
||||||
|
e.y = 100 + (i % 5) * 60
|
||||||
|
e.maxHp = e.hp = 50 // partially damaged -> hp bars render
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderer = (eng as unknown as { renderer: { render(e: unknown): void } }).renderer
|
||||||
|
renderer.render(eng)
|
||||||
|
|
||||||
|
// second frame with slow/dot effects active on all enemies
|
||||||
|
eng.enemies.forEach((e) => {
|
||||||
|
e.slowUntil = eng.time + 1
|
||||||
|
e.dotUntil = eng.time + 1
|
||||||
|
e.dotDps = 5
|
||||||
|
e.dotColor = '#0f0'
|
||||||
|
e.flash = 0.5
|
||||||
|
})
|
||||||
|
renderer.render(eng)
|
||||||
|
|
||||||
|
console.log('Render-Smoke: alle 10 Gegnertypen (2 Frames, mit Slow/DoT/Flash) fehlerfrei gezeichnet ✓')
|
||||||
|
process.exit(0)
|
||||||
|
|
@ -29,7 +29,7 @@ function menu(): void {
|
||||||
<h2 v-else-if="store.result.win">Sieg!</h2>
|
<h2 v-else-if="store.result.win">Sieg!</h2>
|
||||||
<h2 v-else>Game Over</h2>
|
<h2 v-else>Game Over</h2>
|
||||||
<p v-if="store.mp.active" class="msg">{{ store.mp.resultMsg }}</p>
|
<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-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>
|
<p v-else class="msg">Die Basis wurde überrannt. In Welle {{ store.result.wave }}.</p>
|
||||||
|
|
||||||
<div v-if="!store.mp.active" class="stats">
|
<div v-if="!store.mp.active" class="stats">
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import type { MapId } from '@/game/types'
|
||||||
|
|
||||||
const modeText = computed(() =>
|
const modeText = computed(() =>
|
||||||
store.mp.mode === 'coop'
|
store.mp.mode === 'coop'
|
||||||
? { icon: '🤝', title: 'Coop', desc: 'Ein gemeinsames Spielfeld: gemeinsames Gold und Leben, 20 Wellen zusammen.' }
|
? { icon: '🤝', title: 'Coop', desc: 'Ein gemeinsames Spielfeld: gemeinsames Gold und Leben, 30 Wellen zusammen.' }
|
||||||
: { icon: '⚔', title: '1v1', desc: 'Jeder verteidigt sein eigenes Spielfeld (deins ist das große). Den Gegner siehst du live im Mini-Fenster und kannst ihm Rushes schicken!' },
|
: { icon: '⚔', title: '1v1', desc: 'Jeder verteidigt sein eigenes Spielfeld (deins ist das große). Den Gegner siehst du live im Mini-Fenster und kannst ihm Rushes schicken!' },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ async function joinRoom(): Promise<void> {
|
||||||
<span>🏹</span><span>🧨</span><span>❄️</span><span>⚡</span><span>💫</span>
|
<span>🏹</span><span>🧨</span><span>❄️</span><span>⚡</span><span>💫</span>
|
||||||
</div>
|
</div>
|
||||||
<h1>TRXTD</h1>
|
<h1>TRXTD</h1>
|
||||||
<p class="subtitle">Tower Defense im Browser — verteidige deine Basis gegen 20 Wellen!</p>
|
<p class="subtitle">Tower Defense im Browser — verteidige deine Basis gegen 30 Wellen!</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section-title">Karte auswählen</div>
|
<div class="section-title">Karte auswählen</div>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ export const COLS = 20
|
||||||
export const ROWS = 11
|
export const ROWS = 11
|
||||||
export const W = COLS * TILE // 960
|
export const W = COLS * TILE // 960
|
||||||
export const H = ROWS * TILE // 528
|
export const H = ROWS * TILE // 528
|
||||||
export const TOTAL_WAVES = 20
|
export const TOTAL_WAVES = 30
|
||||||
export const INTERMISSION = 20 // seconds between waves
|
export const INTERMISSION = 20 // seconds between waves
|
||||||
export const SELL_RATIO = 0.7
|
export const SELL_RATIO = 0.7
|
||||||
|
|
||||||
|
|
@ -474,12 +474,68 @@ export const ENEMIES: Record<EnemyKind, EnemyDef> = {
|
||||||
flying: false,
|
flying: false,
|
||||||
color: '#d84b3f',
|
color: '#d84b3f',
|
||||||
},
|
},
|
||||||
|
// --- spätere Wellen: stärkere Gegnertypen ---
|
||||||
|
brute: {
|
||||||
|
kind: 'brute',
|
||||||
|
name: 'Brute',
|
||||||
|
hp: 130,
|
||||||
|
speed: 50,
|
||||||
|
reward: 14,
|
||||||
|
radius: 13,
|
||||||
|
dmg: 2,
|
||||||
|
flying: false,
|
||||||
|
color: '#b0763a',
|
||||||
|
},
|
||||||
|
phantom: {
|
||||||
|
kind: 'phantom',
|
||||||
|
name: 'Phantom',
|
||||||
|
hp: 75,
|
||||||
|
speed: 92,
|
||||||
|
reward: 13,
|
||||||
|
radius: 10,
|
||||||
|
dmg: 1,
|
||||||
|
flying: true,
|
||||||
|
color: '#a9c6de',
|
||||||
|
},
|
||||||
|
scorpion: {
|
||||||
|
kind: 'scorpion',
|
||||||
|
name: 'Skorpion',
|
||||||
|
hp: 90,
|
||||||
|
speed: 122,
|
||||||
|
reward: 16,
|
||||||
|
radius: 11,
|
||||||
|
dmg: 2,
|
||||||
|
flying: false,
|
||||||
|
color: '#c2503d',
|
||||||
|
},
|
||||||
|
golem: {
|
||||||
|
kind: 'golem',
|
||||||
|
name: 'Golem',
|
||||||
|
hp: 560,
|
||||||
|
speed: 27,
|
||||||
|
reward: 42,
|
||||||
|
radius: 17,
|
||||||
|
dmg: 3,
|
||||||
|
flying: false,
|
||||||
|
color: '#8a8f7a',
|
||||||
|
},
|
||||||
|
dragon: {
|
||||||
|
kind: 'dragon',
|
||||||
|
name: 'Drache',
|
||||||
|
hp: 950,
|
||||||
|
speed: 56,
|
||||||
|
reward: 85,
|
||||||
|
radius: 18,
|
||||||
|
dmg: 3,
|
||||||
|
flying: true,
|
||||||
|
color: '#9b3a8f',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/** hp multiplier for wave n */
|
/** hp multiplier for wave n (steeper curve: late waves get noticeably tougher) */
|
||||||
export function waveHpScale(n: number): number {
|
export function waveHpScale(n: number): number {
|
||||||
const m = n - 1
|
const m = n - 1
|
||||||
return 1 + 0.18 * m + 0.02 * m * m
|
return 1 + 0.16 * m + 0.025 * m * m
|
||||||
}
|
}
|
||||||
|
|
||||||
/** speed multiplier for wave n */
|
/** speed multiplier for wave n */
|
||||||
|
|
@ -502,7 +558,11 @@ export function earlyCallBonus(remainingSeconds: number): number {
|
||||||
|
|
||||||
const G = (kind: EnemyKind, count: number, interval: number): WaveGroup => ({ kind, count, interval })
|
const G = (kind: EnemyKind, count: number, interval: number): WaveGroup => ({ kind, count, interval })
|
||||||
|
|
||||||
/** hand-tuned waves 1..20 */
|
/**
|
||||||
|
* Hand-tuned campaign waves 1..30.
|
||||||
|
* New enemy types debut progressively: Brute (8), Phantom (12), Skorpion (14),
|
||||||
|
* Golem (18), Drache (22). Wave 30 is the final wall before endless mode.
|
||||||
|
*/
|
||||||
export const WAVES: WaveGroup[][] = [
|
export const WAVES: WaveGroup[][] = [
|
||||||
/* 1 */ [G('normal', 8, 1.1)],
|
/* 1 */ [G('normal', 8, 1.1)],
|
||||||
/* 2 */ [G('normal', 12, 0.9)],
|
/* 2 */ [G('normal', 12, 0.9)],
|
||||||
|
|
@ -511,30 +571,44 @@ export const WAVES: WaveGroup[][] = [
|
||||||
/* 5 */ [G('normal', 10, 0.75), G('tank', 3, 2.2)],
|
/* 5 */ [G('normal', 10, 0.75), G('tank', 3, 2.2)],
|
||||||
/* 6 */ [G('fast', 12, 0.5), G('tank', 4, 2.0)],
|
/* 6 */ [G('fast', 12, 0.5), G('tank', 4, 2.0)],
|
||||||
/* 7 */ [G('flyer', 8, 0.9), G('normal', 10, 0.7)],
|
/* 7 */ [G('flyer', 8, 0.9), G('normal', 10, 0.7)],
|
||||||
/* 8 */ [G('fast', 10, 0.5), G('flyer', 10, 0.7)],
|
/* 8 */ [G('fast', 10, 0.5), G('flyer', 10, 0.7), G('brute', 2, 2.4)],
|
||||||
/* 9 */ [G('tank', 6, 1.6), G('fast', 12, 0.45), G('normal', 10, 0.6)],
|
/* 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)],
|
/* 10 */ [G('boss', 1, 1), G('normal', 12, 0.8)],
|
||||||
/* 11 */ [G('flyer', 14, 0.55), G('fast', 12, 0.45)],
|
/* 11 */ [G('flyer', 14, 0.55), G('fast', 12, 0.45)],
|
||||||
/* 12 */ [G('tank', 8, 1.3), G('normal', 16, 0.5)],
|
/* 12 */ [G('phantom', 6, 0.8), G('flyer', 10, 0.6), G('brute', 4, 1.8)],
|
||||||
/* 13 */ [G('fast', 20, 0.35), G('flyer', 12, 0.5)],
|
/* 13 */ [G('fast', 20, 0.35), G('tank', 6, 1.2), G('phantom', 6, 0.7)],
|
||||||
/* 14 */ [G('tank', 10, 1.1), G('fast', 16, 0.4)],
|
/* 14 */ [G('scorpion', 8, 0.7), G('fast', 16, 0.4), G('tank', 6, 1.2)],
|
||||||
/* 15 */ [G('boss', 2, 8), G('flyer', 10, 0.5)],
|
/* 15 */ [G('boss', 2, 8), G('flyer', 10, 0.5), G('phantom', 6, 0.7)],
|
||||||
/* 16 */ [G('normal', 24, 0.35), G('tank', 8, 1.0)],
|
/* 16 */ [G('normal', 20, 0.35), G('tank', 6, 1.0), G('brute', 5, 1.4)],
|
||||||
/* 17 */ [G('fast', 24, 0.3), G('flyer', 16, 0.45)],
|
/* 17 */ [G('fast', 24, 0.3), G('flyer', 16, 0.45), G('scorpion', 8, 0.65)],
|
||||||
/* 18 */ [G('tank', 12, 0.9), G('normal', 20, 0.4)],
|
/* 18 */ [G('golem', 2, 5), G('tank', 9, 0.9), G('phantom', 7, 0.6)],
|
||||||
/* 19 */ [G('fast', 20, 0.3), G('flyer', 20, 0.4), G('tank', 8, 0.9)],
|
/* 19 */ [G('fast', 20, 0.3), G('flyer', 20, 0.4), G('tank', 8, 0.9), G('scorpion', 6, 0.6)],
|
||||||
/* 20 */ [G('tank', 8, 1.0), G('boss', 2, 8), G('fast', 16, 0.35)],
|
/* 20 */ [G('boss', 2, 8), G('tank', 8, 1.0), G('brute', 8, 1.2), G('fast', 16, 0.35)],
|
||||||
|
/* 21 */ [G('golem', 3, 4), G('scorpion', 10, 0.55), G('phantom', 8, 0.55)],
|
||||||
|
/* 22 */ [G('dragon', 1, 1), G('flyer', 16, 0.45), G('fast', 20, 0.3)],
|
||||||
|
/* 23 */ [G('tank', 14, 0.8), G('golem', 3, 4), G('normal', 24, 0.3)],
|
||||||
|
/* 24 */ [G('scorpion', 14, 0.5), G('phantom', 12, 0.5), G('fast', 24, 0.28)],
|
||||||
|
/* 25 */ [G('boss', 3, 7), G('golem', 3, 4), G('brute', 10, 1.0)],
|
||||||
|
/* 26 */ [G('dragon', 2, 9), G('flyer', 20, 0.4), G('phantom', 10, 0.5)],
|
||||||
|
/* 27 */ [G('tank', 16, 0.7), G('golem', 4, 3.5), G('scorpion', 12, 0.5)],
|
||||||
|
/* 28 */ [G('fast', 30, 0.24), G('phantom', 14, 0.45), G('scorpion', 12, 0.45), G('flyer', 18, 0.35)],
|
||||||
|
/* 29 */ [G('golem', 4, 3), G('brute', 10, 0.9), G('tank', 10, 0.7), G('dragon', 2, 9)],
|
||||||
|
/* 30 */ [G('boss', 3, 6), G('dragon', 2, 8), G('golem', 4, 3), G('scorpion', 12, 0.4)],
|
||||||
]
|
]
|
||||||
|
|
||||||
/** endless mode waves after 20 */
|
/** endless mode waves after 30 */
|
||||||
export function endlessWave(n: number): WaveGroup[] {
|
export function endlessWave(n: number): WaveGroup[] {
|
||||||
const m = n - 20
|
const m = n - 30
|
||||||
const groups: WaveGroup[] = []
|
const groups: WaveGroup[] = []
|
||||||
if (n % 5 === 0) groups.push(G('boss', 1 + Math.floor(m / 5), 8))
|
if (n % 5 === 0) groups.push(G('boss', 1 + Math.floor(m / 4), 7))
|
||||||
groups.push(G('tank', 8 + m * 2, Math.max(0.5, 1.1 - m * 0.02)))
|
if (m >= 2) groups.push(G('dragon', 1 + Math.floor(m / 6), 9))
|
||||||
groups.push(G('fast', 18 + m * 3, Math.max(0.15, 0.32 - m * 0.01)))
|
groups.push(G('golem', 2 + Math.floor(m / 2), Math.max(2.2, 3.5 - m * 0.04)))
|
||||||
groups.push(G('flyer', 14 + m * 2, Math.max(0.25, 0.45 - m * 0.01)))
|
groups.push(G('tank', 10 + m * 2, Math.max(0.5, 1.0 - m * 0.015)))
|
||||||
groups.push(G('normal', 20 + m * 4, Math.max(0.18, 0.35 - m * 0.01)))
|
groups.push(G('scorpion', 10 + m * 2, Math.max(0.3, 0.55 - m * 0.01)))
|
||||||
|
groups.push(G('fast', 20 + m * 3, Math.max(0.15, 0.3 - m * 0.008)))
|
||||||
|
groups.push(G('flyer', 16 + m * 2, Math.max(0.25, 0.4 - m * 0.008)))
|
||||||
|
groups.push(G('phantom', 8 + m * 2, Math.max(0.3, 0.5 - m * 0.01)))
|
||||||
|
groups.push(G('normal', 24 + m * 4, Math.max(0.18, 0.32 - m * 0.008)))
|
||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -576,6 +650,11 @@ export const ENEMY_COLORS: Record<EnemyKind, string> = {
|
||||||
tank: '#7d8894',
|
tank: '#7d8894',
|
||||||
flyer: '#b06ee8',
|
flyer: '#b06ee8',
|
||||||
boss: '#d84b3f',
|
boss: '#d84b3f',
|
||||||
|
brute: '#b0763a',
|
||||||
|
phantom: '#a9c6de',
|
||||||
|
scorpion: '#c2503d',
|
||||||
|
golem: '#8a8f7a',
|
||||||
|
dragon: '#9b3a8f',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ENEMY_ICONS: Record<EnemyKind, string> = {
|
export const ENEMY_ICONS: Record<EnemyKind, string> = {
|
||||||
|
|
@ -584,4 +663,9 @@ export const ENEMY_ICONS: Record<EnemyKind, string> = {
|
||||||
tank: '⬜',
|
tank: '⬜',
|
||||||
flyer: '🟣',
|
flyer: '🟣',
|
||||||
boss: '🔴',
|
boss: '🔴',
|
||||||
|
brute: '🟤',
|
||||||
|
phantom: '⚪',
|
||||||
|
scorpion: '🦂',
|
||||||
|
golem: '🗿',
|
||||||
|
dragon: '🐲',
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -323,6 +323,7 @@ export class GameEngine {
|
||||||
store.muted = sound.muted
|
store.muted = sound.muted
|
||||||
store.difficulty = diff
|
store.difficulty = diff
|
||||||
store.mapId = mapId
|
store.mapId = mapId
|
||||||
|
store.totalWaves = TOTAL_WAVES
|
||||||
store.money = this.money
|
store.money = this.money
|
||||||
store.lives = this.lives
|
store.lives = this.lives
|
||||||
store.maxLives = this.lives
|
store.maxLives = this.lives
|
||||||
|
|
|
||||||
|
|
@ -342,7 +342,7 @@ class MpGameController {
|
||||||
if (this.ended) return
|
if (this.ended) return
|
||||||
if (this.mode === 'coop') {
|
if (this.mode === 'coop') {
|
||||||
if (engine.phase === 'gameover') this.endGame(false, 'Eure Basis ist gefallen…')
|
if (engine.phase === 'gameover') this.endGame(false, 'Eure Basis ist gefallen…')
|
||||||
else if (engine.phase === 'victory') this.endGame(true, 'Alle 20 Wellen gemeinsam geschafft!')
|
else if (engine.phase === 'victory') this.endGame(true, 'Alle 30 Wellen gemeinsam geschafft!')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const mine = this.engines[this.myId]
|
const mine = this.engines[this.myId]
|
||||||
|
|
|
||||||
|
|
@ -773,6 +773,190 @@ export class Renderer {
|
||||||
g.fill()
|
g.fill()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case 'brute': {
|
||||||
|
g.scale(1, squish)
|
||||||
|
// fists
|
||||||
|
const swing = Math.sin(eng.time * 7 + e.wobble) * 2
|
||||||
|
g.fillStyle = '#7d5024'
|
||||||
|
g.beginPath()
|
||||||
|
g.arc(-e.r - 2, 2 + swing, e.r * 0.45, 0, Math.PI * 2)
|
||||||
|
g.arc(e.r + 2, 2 - swing, e.r * 0.45, 0, Math.PI * 2)
|
||||||
|
g.fill()
|
||||||
|
// bulky body
|
||||||
|
const bg = g.createRadialGradient(-3, -4, 2, 0, 0, e.r)
|
||||||
|
bg.addColorStop(0, '#d29a56')
|
||||||
|
bg.addColorStop(1, body)
|
||||||
|
g.fillStyle = bg
|
||||||
|
g.beginPath()
|
||||||
|
g.arc(0, 0, e.r, 0, Math.PI * 2)
|
||||||
|
g.fill()
|
||||||
|
// angry eyes
|
||||||
|
g.fillStyle = '#ffe9d2'
|
||||||
|
g.beginPath()
|
||||||
|
g.arc(-4, -3, 3, 0, Math.PI * 2)
|
||||||
|
g.arc(4, -3, 3, 0, Math.PI * 2)
|
||||||
|
g.fill()
|
||||||
|
g.fillStyle = '#401c08'
|
||||||
|
g.beginPath()
|
||||||
|
g.arc(-3.4, -2.4, 1.5, 0, Math.PI * 2)
|
||||||
|
g.arc(4.6, -2.4, 1.5, 0, Math.PI * 2)
|
||||||
|
g.fill()
|
||||||
|
g.strokeStyle = '#401c08'
|
||||||
|
g.lineWidth = 2
|
||||||
|
g.beginPath()
|
||||||
|
g.moveTo(-7, -7)
|
||||||
|
g.lineTo(-1.5, -5)
|
||||||
|
g.moveTo(7, -7)
|
||||||
|
g.lineTo(1.5, -5)
|
||||||
|
g.stroke()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'phantom': {
|
||||||
|
g.globalAlpha = 0.78
|
||||||
|
const wv = Math.sin(eng.time * 5 + e.wobble)
|
||||||
|
g.fillStyle = body
|
||||||
|
g.beginPath()
|
||||||
|
g.arc(0, -2, e.r * 0.9, Math.PI, 0)
|
||||||
|
g.lineTo(e.r * 0.9, e.r * 0.7)
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const wx = e.r * 0.9 - (i * e.r * 0.6)
|
||||||
|
g.quadraticCurveTo(wx - e.r * 0.15, e.r * (1.1 + wv * 0.15), wx - e.r * 0.6, e.r * 0.7)
|
||||||
|
}
|
||||||
|
g.closePath()
|
||||||
|
g.fill()
|
||||||
|
// hollow eyes
|
||||||
|
g.globalAlpha = 1
|
||||||
|
g.fillStyle = '#1d2c3a'
|
||||||
|
g.beginPath()
|
||||||
|
g.ellipse(-3.5, -3, 2.2, 3.2, 0, 0, Math.PI * 2)
|
||||||
|
g.ellipse(3.5, -3, 2.2, 3.2, 0, 0, Math.PI * 2)
|
||||||
|
g.fill()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'scorpion': {
|
||||||
|
g.rotate(e.heading)
|
||||||
|
// tail
|
||||||
|
g.strokeStyle = '#8e3524'
|
||||||
|
g.lineWidth = 3
|
||||||
|
g.beginPath()
|
||||||
|
g.moveTo(-e.r * 0.6, 0)
|
||||||
|
g.quadraticCurveTo(-e.r * 1.6, -e.r * 0.9, -e.r * 1.1, -e.r * 1.6)
|
||||||
|
g.stroke()
|
||||||
|
g.fillStyle = '#ffd23e'
|
||||||
|
g.beginPath()
|
||||||
|
g.arc(-e.r * 1.1, -e.r * 1.6, 2.6, 0, Math.PI * 2)
|
||||||
|
g.fill()
|
||||||
|
// body segments
|
||||||
|
g.fillStyle = body
|
||||||
|
g.beginPath()
|
||||||
|
g.ellipse(0, 0, e.r, e.r * 0.62, 0, 0, Math.PI * 2)
|
||||||
|
g.fill()
|
||||||
|
g.fillStyle = '#8e3524'
|
||||||
|
g.beginPath()
|
||||||
|
g.ellipse(-e.r * 0.45, 0, e.r * 0.5, e.r * 0.4, 0, 0, Math.PI * 2)
|
||||||
|
g.fill()
|
||||||
|
// pincers
|
||||||
|
const snap = Math.sin(eng.time * 9 + e.wobble) * 0.25
|
||||||
|
g.strokeStyle = body
|
||||||
|
g.lineWidth = 2.5
|
||||||
|
for (const s of [-1, 1]) {
|
||||||
|
g.beginPath()
|
||||||
|
g.moveTo(e.r * 0.7, s * e.r * 0.3)
|
||||||
|
g.lineTo(e.r * 1.15, s * e.r * (0.75 + snap))
|
||||||
|
g.stroke()
|
||||||
|
g.fillStyle = '#8e3524'
|
||||||
|
g.beginPath()
|
||||||
|
g.arc(e.r * 1.15, s * e.r * (0.75 + snap), 2.8, 0, Math.PI * 2)
|
||||||
|
g.fill()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'golem': {
|
||||||
|
const pulse = 0.5 + Math.sin(eng.time * 3 + e.wobble) * 0.5
|
||||||
|
// stone body
|
||||||
|
const sg = g.createLinearGradient(0, -e.r, 0, e.r)
|
||||||
|
sg.addColorStop(0, '#a3a88f')
|
||||||
|
sg.addColorStop(1, '#6c705c')
|
||||||
|
g.fillStyle = sg
|
||||||
|
this.roundRect(g, -e.r, -e.r, e.r * 2, e.r * 2, 7)
|
||||||
|
g.fill()
|
||||||
|
// cracks
|
||||||
|
g.strokeStyle = 'rgba(40,44,32,0.7)'
|
||||||
|
g.lineWidth = 1.5
|
||||||
|
g.beginPath()
|
||||||
|
g.moveTo(-e.r * 0.6, -e.r)
|
||||||
|
g.lineTo(-e.r * 0.3, -e.r * 0.4)
|
||||||
|
g.lineTo(-e.r * 0.55, e.r * 0.2)
|
||||||
|
g.moveTo(e.r * 0.5, -e.r * 0.8)
|
||||||
|
g.lineTo(e.r * 0.25, -e.r * 0.1)
|
||||||
|
g.stroke()
|
||||||
|
// glowing core
|
||||||
|
g.fillStyle = `rgba(255,${140 + pulse * 60},60,${0.65 + pulse * 0.35})`
|
||||||
|
g.beginPath()
|
||||||
|
g.arc(0, 0, 5.5, 0, Math.PI * 2)
|
||||||
|
g.fill()
|
||||||
|
// deep-set eyes
|
||||||
|
g.fillStyle = '#2b2f22'
|
||||||
|
g.beginPath()
|
||||||
|
g.arc(-e.r * 0.4, -e.r * 0.45, 2.6, 0, Math.PI * 2)
|
||||||
|
g.arc(e.r * 0.4, -e.r * 0.45, 2.6, 0, Math.PI * 2)
|
||||||
|
g.fill()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'dragon': {
|
||||||
|
// big flapping wings
|
||||||
|
const flap = Math.sin(eng.time * 9 + e.wobble)
|
||||||
|
g.fillStyle = 'rgba(70,25,80,0.85)'
|
||||||
|
for (const s of [-1, 1]) {
|
||||||
|
g.beginPath()
|
||||||
|
g.moveTo(0, -2)
|
||||||
|
g.quadraticCurveTo(s * e.r * 1.9, -e.r * (1.1 + flap * 0.3), s * e.r * 2.1, e.r * 0.2)
|
||||||
|
g.quadraticCurveTo(s * e.r * 1.1, -e.r * 0.1, 0, 4)
|
||||||
|
g.closePath()
|
||||||
|
g.fill()
|
||||||
|
}
|
||||||
|
// spiky body
|
||||||
|
g.fillStyle = '#5e2456'
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const a = (i / 8) * Math.PI * 2 + eng.time * 0.4
|
||||||
|
g.beginPath()
|
||||||
|
g.moveTo(Math.cos(a) * (e.r - 3), Math.sin(a) * (e.r - 3))
|
||||||
|
g.lineTo(Math.cos(a + 0.2) * (e.r + 5), Math.sin(a + 0.2) * (e.r + 5))
|
||||||
|
g.lineTo(Math.cos(a + 0.4) * (e.r - 3), Math.sin(a + 0.4) * (e.r - 3))
|
||||||
|
g.closePath()
|
||||||
|
g.fill()
|
||||||
|
}
|
||||||
|
const dg = g.createRadialGradient(-4, -4, 3, 0, 0, e.r)
|
||||||
|
dg.addColorStop(0, '#d76ccb')
|
||||||
|
dg.addColorStop(1, body)
|
||||||
|
g.fillStyle = dg
|
||||||
|
g.beginPath()
|
||||||
|
g.arc(0, 0, e.r, 0, Math.PI * 2)
|
||||||
|
g.fill()
|
||||||
|
// horns
|
||||||
|
g.fillStyle = '#ffd23e'
|
||||||
|
g.beginPath()
|
||||||
|
g.moveTo(-7, -e.r + 2)
|
||||||
|
g.lineTo(-9, -e.r - 6)
|
||||||
|
g.lineTo(-4, -e.r + 3)
|
||||||
|
g.moveTo(7, -e.r + 2)
|
||||||
|
g.lineTo(9, -e.r - 6)
|
||||||
|
g.lineTo(4, -e.r + 3)
|
||||||
|
g.closePath()
|
||||||
|
g.fill()
|
||||||
|
// burning eyes
|
||||||
|
g.fillStyle = '#ffe14d'
|
||||||
|
g.beginPath()
|
||||||
|
g.arc(-5, -3, 3, 0, Math.PI * 2)
|
||||||
|
g.arc(5, -3, 3, 0, Math.PI * 2)
|
||||||
|
g.fill()
|
||||||
|
g.fillStyle = '#c22'
|
||||||
|
g.beginPath()
|
||||||
|
g.arc(-5, -3, 1.4, 0, Math.PI * 2)
|
||||||
|
g.arc(5, -3, 1.4, 0, Math.PI * 2)
|
||||||
|
g.fill()
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// slow indicator
|
// slow indicator
|
||||||
|
|
@ -822,12 +1006,13 @@ export class Renderer {
|
||||||
}
|
}
|
||||||
g.restore()
|
g.restore()
|
||||||
|
|
||||||
// hp bar
|
// hp bar (bosses/golems/dragons always show, raised higher)
|
||||||
const pct = Math.max(0, e.hp / e.maxHp)
|
const pct = Math.max(0, e.hp / e.maxHp)
|
||||||
if (pct < 1 || e.kind === 'boss') {
|
const big = e.kind === 'boss' || e.kind === 'golem' || e.kind === 'dragon'
|
||||||
|
if (pct < 1 || big) {
|
||||||
const bw = Math.max(20, e.r * 2.2)
|
const bw = Math.max(20, e.r * 2.2)
|
||||||
const bx = x - bw / 2
|
const bx = x - bw / 2
|
||||||
const by = y + bob - e.r - (e.kind === 'boss' ? 16 : 9)
|
const by = y + bob - e.r - (big ? 16 : 9)
|
||||||
g.fillStyle = 'rgba(0,0,0,0.55)'
|
g.fillStyle = 'rgba(0,0,0,0.55)'
|
||||||
g.fillRect(bx - 1, by - 1, bw + 2, 5.5)
|
g.fillRect(bx - 1, by - 1, bw + 2, 5.5)
|
||||||
g.fillStyle = pct > 0.5 ? '#5cd74c' : pct > 0.25 ? '#ffd23e' : '#ff5f4e'
|
g.fillStyle = pct > 0.5 ? '#5cd74c' : pct > 0.25 ? '#ffd23e' : '#ff5f4e'
|
||||||
|
|
@ -981,7 +1166,7 @@ export class Renderer {
|
||||||
}
|
}
|
||||||
|
|
||||||
private drawBossBars(g: CanvasRenderingContext2D, eng: GameEngine): void {
|
private drawBossBars(g: CanvasRenderingContext2D, eng: GameEngine): void {
|
||||||
const bosses = eng.enemies.filter((e) => e.kind === 'boss' && !e.dead)
|
const bosses = eng.enemies.filter((e) => (e.kind === 'boss' || e.kind === 'dragon') && !e.dead)
|
||||||
if (bosses.length === 0) return
|
if (bosses.length === 0) return
|
||||||
const bw = 300
|
const bw = 300
|
||||||
for (let i = 0; i < bosses.length; i++) {
|
for (let i = 0; i < bosses.length; i++) {
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ export const store = reactive({
|
||||||
lives: 0,
|
lives: 0,
|
||||||
maxLives: 0,
|
maxLives: 0,
|
||||||
waveNo: 0,
|
waveNo: 0,
|
||||||
totalWaves: 20,
|
totalWaves: 30,
|
||||||
endless: false,
|
endless: false,
|
||||||
|
|
||||||
phase: 'idle' as Phase,
|
phase: 'idle' as Phase,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,15 @@
|
||||||
export type TowerKind = 'arrow' | 'cannon' | 'frost' | 'tesla' | 'laser'
|
export type TowerKind = 'arrow' | 'cannon' | 'frost' | 'tesla' | 'laser'
|
||||||
export type EnemyKind = 'normal' | 'fast' | 'tank' | 'flyer' | 'boss'
|
export type EnemyKind =
|
||||||
|
| 'normal'
|
||||||
|
| 'fast'
|
||||||
|
| 'tank'
|
||||||
|
| 'flyer'
|
||||||
|
| 'boss'
|
||||||
|
| 'brute'
|
||||||
|
| 'phantom'
|
||||||
|
| 'scorpion'
|
||||||
|
| 'golem'
|
||||||
|
| 'dragon'
|
||||||
export type TargetingMode = 'first' | 'last' | 'strong' | 'close'
|
export type TargetingMode = 'first' | 'last' | 'strong' | 'close'
|
||||||
export type Phase = 'idle' | 'intermission' | 'wave' | 'gameover' | 'victory'
|
export type Phase = 'idle' | 'intermission' | 'wave' | 'gameover' | 'victory'
|
||||||
export type Screen = 'menu' | 'lobby' | 'game' | 'gameover' | 'victory'
|
export type Screen = 'menu' | 'lobby' | 'game' | 'gameover' | 'victory'
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue