From aa68343ac21a315b2853e901449c4ed319348812 Mon Sep 17 00:00:00 2001 From: Tronax Date: Mon, 17 Aug 2026 20:29:47 +0200 Subject: [PATCH] feat(balance): 30-wave campaign, 5 new enemy types, steeper per-wave scaling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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". --- README.md | 9 +- package.json | 2 +- scripts/sim.mts | 16 ++- scripts/test-render-smoke.mts | 71 ++++++++++++ src/components/EndOverlay.vue | 2 +- src/components/LobbyScreen.vue | 2 +- src/components/StartScreen.vue | 2 +- src/game/config.ts | 126 +++++++++++++++++---- src/game/engine.ts | 1 + src/game/mpgame.ts | 2 +- src/game/render.ts | 193 ++++++++++++++++++++++++++++++++- src/game/store.ts | 2 +- src/game/types.ts | 12 +- 13 files changed, 402 insertions(+), 38 deletions(-) create mode 100644 scripts/test-render-smoke.mts diff --git a/README.md b/README.md index 8548e7f..524dff4 100644 --- a/README.md +++ b/README.md @@ -152,11 +152,18 @@ Türme ab Level 4 erhalten im Spiel eine pulsierende **Evolutions-Aura** (orange ## 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 - **🟡 Läufer:** Sehr schnell – Bogentürme oder Eisturm nötig - **⬜ Panzer:** Zäh, kostet **2 Leben** bei Durchbruch - **🟣 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 --- diff --git a/package.json b/package.json index cb506d3..ecd3571 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "build": "vue-tsc --noEmit && vite build", "preview": "vite preview", "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": { "vue": "^3.5.13", diff --git a/scripts/sim.mts b/scripts/sim.mts index 3700ca1..653468e 100644 --- a/scripts/sim.mts +++ b/scripts/sim.mts @@ -1,6 +1,12 @@ /** - * Headless balance simulation: a greedy bot plays the game on 'normal' - * to verify the campaign is winnable (and not trivially easy). + * Headless balance simulation: a greedy bot plays the campaign on 'normal' + * 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 */ @@ -8,8 +14,8 @@ import { GameEngine } from '../src/game/engine.ts' import { TOWERS } from '../src/game/config.ts' import type { TowerKind } from '../src/game/types.ts' -/** campaign balance stays tuned for 3 levels – evolutions are endless-mode luxury */ -const SIM_MAX_LEVEL = 3 +/** upper tower level bound for the greedy bot (it rarely reaches it) */ +const SIM_MAX_LEVEL = 9 const eng = new GameEngine() eng.startGame('normal') @@ -153,7 +159,7 @@ while (eng.phase !== 'gameover' && eng.phase !== 'victory' && guard < 60 * 60 * console.log('---') console.log(`Ergebnis: ${eng.phase === 'victory' ? 'SIEG' : 'NIEDERLAGE'} in Welle ${eng.waveNo}, Leben übrig: ${eng.lives}, Punkte: ${eng.score}`) 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) { console.log(' → Balancing zu leicht (zu viele Leben übrig).') } diff --git a/scripts/test-render-smoke.mts b/scripts/test-render-smoke.mts new file mode 100644 index 0000000..70c545e --- /dev/null +++ b/scripts/test-render-smoke.mts @@ -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 => ({ + width: 960, + height: 528, + style: {}, + getContext: () => makeCtx(), + addEventListener: noop, + removeEventListener: noop, +}) + +const makeCtx = (): Record => ({ + 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).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) diff --git a/src/components/EndOverlay.vue b/src/components/EndOverlay.vue index b76ed84..1b35707 100644 --- a/src/components/EndOverlay.vue +++ b/src/components/EndOverlay.vue @@ -29,7 +29,7 @@ function menu(): void {

Sieg!

Game Over

{{ store.mp.resultMsg }}

-

Alle 20 Wellen abgewehrt — die Basis steht!

+

Alle 30 Wellen abgewehrt — die Basis steht!

Die Basis wurde überrannt. In Welle {{ store.result.wave }}.

diff --git a/src/components/LobbyScreen.vue b/src/components/LobbyScreen.vue index 4048fe6..9be95b3 100644 --- a/src/components/LobbyScreen.vue +++ b/src/components/LobbyScreen.vue @@ -7,7 +7,7 @@ import type { MapId } from '@/game/types' const modeText = computed(() => 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!' }, ) diff --git a/src/components/StartScreen.vue b/src/components/StartScreen.vue index 71d0471..928e1db 100644 --- a/src/components/StartScreen.vue +++ b/src/components/StartScreen.vue @@ -59,7 +59,7 @@ async function joinRoom(): Promise { 🏹🧨❄️💫

TRXTD

-

Tower Defense im Browser — verteidige deine Basis gegen 20 Wellen!

+

Tower Defense im Browser — verteidige deine Basis gegen 30 Wellen!

Karte auswählen
diff --git a/src/game/config.ts b/src/game/config.ts index 8e16639..1931c14 100644 --- a/src/game/config.ts +++ b/src/game/config.ts @@ -5,7 +5,7 @@ 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 TOTAL_WAVES = 30 export const INTERMISSION = 20 // seconds between waves export const SELL_RATIO = 0.7 @@ -474,12 +474,68 @@ export const ENEMIES: Record = { flying: false, 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 { 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 */ @@ -502,7 +558,11 @@ export function earlyCallBonus(remainingSeconds: number): number { 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[][] = [ /* 1 */ [G('normal', 8, 1.1)], /* 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)], /* 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)], + /* 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)], /* 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)], + /* 12 */ [G('phantom', 6, 0.8), G('flyer', 10, 0.6), G('brute', 4, 1.8)], + /* 13 */ [G('fast', 20, 0.35), G('tank', 6, 1.2), G('phantom', 6, 0.7)], + /* 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), G('phantom', 6, 0.7)], + /* 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), G('scorpion', 8, 0.65)], + /* 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), G('scorpion', 6, 0.6)], + /* 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[] { - const m = n - 20 + const m = n - 30 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))) + if (n % 5 === 0) groups.push(G('boss', 1 + Math.floor(m / 4), 7)) + if (m >= 2) groups.push(G('dragon', 1 + Math.floor(m / 6), 9)) + groups.push(G('golem', 2 + Math.floor(m / 2), Math.max(2.2, 3.5 - m * 0.04))) + groups.push(G('tank', 10 + m * 2, Math.max(0.5, 1.0 - m * 0.015))) + 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 } @@ -576,6 +650,11 @@ export const ENEMY_COLORS: Record = { tank: '#7d8894', flyer: '#b06ee8', boss: '#d84b3f', + brute: '#b0763a', + phantom: '#a9c6de', + scorpion: '#c2503d', + golem: '#8a8f7a', + dragon: '#9b3a8f', } export const ENEMY_ICONS: Record = { @@ -584,4 +663,9 @@ export const ENEMY_ICONS: Record = { tank: '⬜', flyer: '🟣', boss: '🔴', + brute: '🟤', + phantom: '⚪', + scorpion: '🦂', + golem: '🗿', + dragon: '🐲', } diff --git a/src/game/engine.ts b/src/game/engine.ts index 45dbe3c..5157e67 100644 --- a/src/game/engine.ts +++ b/src/game/engine.ts @@ -323,6 +323,7 @@ export class GameEngine { store.muted = sound.muted store.difficulty = diff store.mapId = mapId + store.totalWaves = TOTAL_WAVES store.money = this.money store.lives = this.lives store.maxLives = this.lives diff --git a/src/game/mpgame.ts b/src/game/mpgame.ts index c5df4b1..6b6a108 100644 --- a/src/game/mpgame.ts +++ b/src/game/mpgame.ts @@ -342,7 +342,7 @@ class MpGameController { 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!') + else if (engine.phase === 'victory') this.endGame(true, 'Alle 30 Wellen gemeinsam geschafft!') return } const mine = this.engines[this.myId] diff --git a/src/game/render.ts b/src/game/render.ts index b6d0ee3..9a51a47 100644 --- a/src/game/render.ts +++ b/src/game/render.ts @@ -773,6 +773,190 @@ export class Renderer { g.fill() 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 @@ -822,12 +1006,13 @@ export class Renderer { } g.restore() - // hp bar + // hp bar (bosses/golems/dragons always show, raised higher) 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 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.fillRect(bx - 1, by - 1, bw + 2, 5.5) 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 { - 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 const bw = 300 for (let i = 0; i < bosses.length; i++) { diff --git a/src/game/store.ts b/src/game/store.ts index 66b4253..c73765d 100644 --- a/src/game/store.ts +++ b/src/game/store.ts @@ -29,7 +29,7 @@ export const store = reactive({ lives: 0, maxLives: 0, waveNo: 0, - totalWaves: 20, + totalWaves: 30, endless: false, phase: 'idle' as Phase, diff --git a/src/game/types.ts b/src/game/types.ts index 494ff1d..adaca9e 100644 --- a/src/game/types.ts +++ b/src/game/types.ts @@ -1,5 +1,15 @@ 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 Phase = 'idle' | 'intermission' | 'wave' | 'gameover' | 'victory' export type Screen = 'menu' | 'lobby' | 'game' | 'gameover' | 'victory'