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
|
|
@ -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).')
|
||||
}
|
||||
|
|
|
|||
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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue