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".
671 lines
19 KiB
TypeScript
671 lines
19 KiB
TypeScript
import type { DifficultyDef, DifficultyId, EnemyDef, EnemyKind, MapDef, MapId, 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 = 30
|
||
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,
|
||
}
|
||
|
||
/** multiplayer player identity colors (blue = player 1, orange = player 2) */
|
||
export const PLAYER_COLORS: [string, string] = ['#4da3ff', '#ffb14d']
|
||
|
||
export function playerColor(playerId: number | null): string {
|
||
return playerId === null || playerId === 0 ? PLAYER_COLORS[0] : PLAYER_COLORS[1]
|
||
}
|
||
|
||
export const MAPS: Record<MapId, MapDef> = {
|
||
meadow: {
|
||
id: 'meadow',
|
||
name: 'Grüne Lichtung',
|
||
subtitle: 'Klassisches Tal',
|
||
desc: 'Ausgewogener S-Kurven-Pfad durch saftige Wiesen mit vielen strategischen Bauplätzen.',
|
||
icon: '🌿',
|
||
theme: {
|
||
biome: 'grassland',
|
||
bgGradient: ['#3d6b39', '#48783f'],
|
||
checkerAlpha: 0.022,
|
||
gridColor: 'rgba(0,0,0,0.06)',
|
||
speckleDark: 'rgba(0,0,0,0.05)',
|
||
speckleLight: 'rgba(255,255,255,0.04)',
|
||
pathBorder: '#5f4a33',
|
||
pathEdge: '#7a6142',
|
||
pathMain: '#c2a276',
|
||
pathDash: 'rgba(216,195,154,0.55)',
|
||
pathPebbles: ['rgba(95,74,51,0.5)', 'rgba(230,210,175,0.5)'],
|
||
portalColor: '#2a1e3d',
|
||
portalRing: 'rgba(178,120,255,0.7)',
|
||
portalCore: 'rgba(200,160,255,0.9)',
|
||
baseColor: '#8d8577',
|
||
baseAccent: '#a49b8b',
|
||
treeFoliage: ['#2f6b33', '#3b7c3c'],
|
||
treeTrunk: '#6b4526',
|
||
rockColor: '#8b9298',
|
||
rockHighlight: 'rgba(255,255,255,0.22)',
|
||
bushColor: ['#3e7a3a', '#4f8f45'],
|
||
flowers: ['#e8657f', '#f5d55b', '#ffffff', '#c17ee0'],
|
||
obstacleTypes: { primary: 'tree', secondary: 'rock' },
|
||
},
|
||
waypoints: [
|
||
[-1, 5],
|
||
[2, 5],
|
||
[2, 1],
|
||
[6, 1],
|
||
[6, 9],
|
||
[10, 9],
|
||
[10, 1],
|
||
[14, 1],
|
||
[14, 9],
|
||
[17, 9],
|
||
[17, 5],
|
||
[20, 5],
|
||
],
|
||
flyWaypoints: [
|
||
[-1, 5],
|
||
[20, 5],
|
||
],
|
||
decorCounts: {
|
||
primaryObstacle: 7,
|
||
secondaryObstacle: 5,
|
||
bush: 10,
|
||
detail: 22,
|
||
},
|
||
decorDistLimits: {
|
||
primaryDist: [2.1, 99],
|
||
secondaryDist: [1.6, 99],
|
||
},
|
||
},
|
||
desert: {
|
||
id: 'desert',
|
||
name: 'Sonnendünen',
|
||
subtitle: 'Glühende Wüste',
|
||
desc: 'Langer, gewundener Sanddünenpfad mit Palmen, Felsblöcken und Kakteen.',
|
||
icon: '🏜️',
|
||
theme: {
|
||
biome: 'desert',
|
||
bgGradient: ['#c99a4e', '#d8a95d'],
|
||
checkerAlpha: 0.035,
|
||
gridColor: 'rgba(110,65,15,0.08)',
|
||
speckleDark: 'rgba(90,50,10,0.08)',
|
||
speckleLight: 'rgba(255,240,190,0.08)',
|
||
pathBorder: '#7d4a1b',
|
||
pathEdge: '#9a602a',
|
||
pathMain: '#e6c88b',
|
||
pathDash: 'rgba(255,245,210,0.6)',
|
||
pathPebbles: ['rgba(125,74,27,0.5)', 'rgba(255,230,170,0.5)'],
|
||
portalColor: '#3a2010',
|
||
portalRing: 'rgba(255,170,60,0.8)',
|
||
portalCore: 'rgba(255,220,130,0.95)',
|
||
baseColor: '#b58a5c',
|
||
baseAccent: '#d4aa7c',
|
||
treeFoliage: ['#3e803a', '#5ba040'], // Palm fronds
|
||
treeTrunk: '#8a623a',
|
||
rockColor: '#ba7a4e', // Sandstone rocks
|
||
rockHighlight: 'rgba(255,235,190,0.35)',
|
||
bushColor: ['#7a8f35', '#9ab540'], // Desert scrub
|
||
flowers: ['#ff8a43', '#ffc83b', '#e05a47', '#ffdf80'],
|
||
obstacleTypes: { primary: 'tree', secondary: 'rock' },
|
||
},
|
||
waypoints: [
|
||
[-1, 2],
|
||
[4, 2],
|
||
[4, 8],
|
||
[8, 8],
|
||
[8, 2],
|
||
[12, 2],
|
||
[12, 8],
|
||
[16, 8],
|
||
[16, 3],
|
||
[20, 3],
|
||
],
|
||
flyWaypoints: [
|
||
[-1, 2],
|
||
[20, 3],
|
||
],
|
||
decorCounts: {
|
||
primaryObstacle: 6,
|
||
secondaryObstacle: 6,
|
||
bush: 8,
|
||
detail: 16,
|
||
},
|
||
decorDistLimits: {
|
||
primaryDist: [2.0, 99],
|
||
secondaryDist: [1.5, 99],
|
||
},
|
||
},
|
||
frostland: {
|
||
id: 'frostland',
|
||
name: 'Frostgipfel',
|
||
subtitle: 'Eisige Einöde',
|
||
desc: 'Ein eisiger Serpentinenweg durch Schnee und gefrorene Tannenwälder.',
|
||
icon: '❄️',
|
||
theme: {
|
||
biome: 'snow',
|
||
bgGradient: ['#7d9bb8', '#90abc7'],
|
||
checkerAlpha: 0.03,
|
||
gridColor: 'rgba(40,70,110,0.08)',
|
||
speckleDark: 'rgba(30,60,95,0.07)',
|
||
speckleLight: 'rgba(255,255,255,0.18)',
|
||
pathBorder: '#3b556b',
|
||
pathEdge: '#526f87',
|
||
pathMain: '#c8dceb',
|
||
pathDash: 'rgba(235,245,255,0.7)',
|
||
pathPebbles: ['rgba(60,90,115,0.5)', 'rgba(240,250,255,0.7)'],
|
||
portalColor: '#122538',
|
||
portalRing: 'rgba(120,210,255,0.85)',
|
||
portalCore: 'rgba(200,240,255,0.95)',
|
||
baseColor: '#607991',
|
||
baseAccent: '#819db8',
|
||
treeFoliage: ['#1e4a4d', '#2c6663'], // Pine trees covered in snow
|
||
treeTrunk: '#423b38',
|
||
rockColor: '#5c7891', // Frosty ice crystals/rocks
|
||
rockHighlight: 'rgba(215,245,255,0.45)',
|
||
bushColor: ['#3a6369', '#507f82'],
|
||
flowers: ['#bfe8ff', '#ffffff', '#90d4ff', '#e0f4ff'],
|
||
obstacleTypes: { primary: 'tree', secondary: 'rock' },
|
||
},
|
||
waypoints: [
|
||
[-1, 8],
|
||
[3, 8],
|
||
[3, 2],
|
||
[7, 2],
|
||
[7, 9],
|
||
[13, 9],
|
||
[13, 3],
|
||
[17, 3],
|
||
[17, 7],
|
||
[20, 7],
|
||
],
|
||
flyWaypoints: [
|
||
[-1, 8],
|
||
[20, 7],
|
||
],
|
||
decorCounts: {
|
||
primaryObstacle: 8,
|
||
secondaryObstacle: 5,
|
||
bush: 9,
|
||
detail: 20,
|
||
},
|
||
decorDistLimits: {
|
||
primaryDist: [2.0, 99],
|
||
secondaryDist: [1.6, 99],
|
||
},
|
||
},
|
||
volcano: {
|
||
id: 'volcano',
|
||
name: 'Lavabruch',
|
||
subtitle: 'Feurige Schlucht',
|
||
desc: 'Aggressiver, enger Pfad durch erkaltetes Lavagestein mit glühenden Magmaadern.',
|
||
icon: '🌋',
|
||
theme: {
|
||
biome: 'magma',
|
||
bgGradient: ['#282024', '#332629'],
|
||
checkerAlpha: 0.04,
|
||
gridColor: 'rgba(255,80,20,0.06)',
|
||
speckleDark: 'rgba(0,0,0,0.12)',
|
||
speckleLight: 'rgba(255,110,40,0.08)',
|
||
pathBorder: '#521f18',
|
||
pathEdge: '#782d20',
|
||
pathMain: '#e86a38',
|
||
pathDash: 'rgba(255,200,100,0.65)',
|
||
pathPebbles: ['rgba(80,25,20,0.6)', 'rgba(255,160,60,0.6)'],
|
||
portalColor: '#2b0d09',
|
||
portalRing: 'rgba(255,80,30,0.9)',
|
||
portalCore: 'rgba(255,200,80,0.95)',
|
||
baseColor: '#4d3b3b',
|
||
baseAccent: '#6b4f4f',
|
||
treeFoliage: ['#382b28', '#473430'], // Charred dead trees
|
||
treeTrunk: '#1a1414',
|
||
rockColor: '#3d3434', // Basalt obsidian rocks
|
||
rockHighlight: 'rgba(255,130,50,0.4)',
|
||
bushColor: ['#472c26', '#5e3730'],
|
||
flowers: ['#ff4d29', '#ff9436', '#ffd24d', '#ff2a2a'], // Glowing embers
|
||
obstacleTypes: { primary: 'tree', secondary: 'rock' },
|
||
},
|
||
waypoints: [
|
||
[-1, 1],
|
||
[4, 1],
|
||
[4, 6],
|
||
[1, 6],
|
||
[1, 9],
|
||
[8, 9],
|
||
[8, 4],
|
||
[14, 4],
|
||
[14, 8],
|
||
[18, 8],
|
||
[18, 2],
|
||
[20, 2],
|
||
],
|
||
flyWaypoints: [
|
||
[-1, 1],
|
||
[20, 2],
|
||
],
|
||
decorCounts: {
|
||
primaryObstacle: 7,
|
||
secondaryObstacle: 6,
|
||
bush: 8,
|
||
detail: 24,
|
||
},
|
||
decorDistLimits: {
|
||
primaryDist: [1.9, 99],
|
||
secondaryDist: [1.5, 99],
|
||
},
|
||
},
|
||
}
|
||
|
||
export const MAP_ORDER: MapId[] = ['meadow', 'desert', 'frostland', 'volcano']
|
||
|
||
/** Default fallback waypoints for backward compatibility */
|
||
export const MAP_WAYPOINTS: [number, number][] = MAPS.meadow.waypoints
|
||
export const FLY_WAYPOINTS: [number, number][] = MAPS.meadow.flyWaypoints
|
||
|
||
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, 260, 420, 700, 1200, 2000, 3400],
|
||
levels: [
|
||
{ damage: 12, range: 110, rate: 1.7 },
|
||
{ damage: 22, range: 120, rate: 2.0 },
|
||
{ damage: 38, range: 132, rate: 2.4 },
|
||
{ damage: 70, range: 140, rate: 2.6 },
|
||
{ damage: 95, range: 148, rate: 2.8 },
|
||
{ damage: 125, range: 155, rate: 3.0 },
|
||
{ damage: 180, range: 165, rate: 3.2 },
|
||
{ damage: 240, range: 175, rate: 3.4 },
|
||
{ damage: 320, range: 185, rate: 3.6 },
|
||
],
|
||
hitsFlying: true,
|
||
projectileSpeed: 460,
|
||
multi: [1, 1, 1, 2, 2, 3, 3, 3, 4],
|
||
dotDps: [0, 0, 0, 0, 0, 0, 12, 18, 28],
|
||
dotDuration: [0, 0, 0, 0, 0, 0, 2, 2.5, 3],
|
||
dotColor: '#7ddc4f',
|
||
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, 380, 600, 950, 1600, 2600, 4200],
|
||
levels: [
|
||
{ damage: 28, range: 118, rate: 0.55 },
|
||
{ damage: 50, range: 128, rate: 0.62 },
|
||
{ damage: 92, range: 140, rate: 0.7 },
|
||
{ damage: 150, range: 150, rate: 0.75 },
|
||
{ damage: 200, range: 158, rate: 0.8 },
|
||
{ damage: 260, range: 165, rate: 0.85 },
|
||
{ damage: 380, range: 175, rate: 0.9 },
|
||
{ damage: 500, range: 185, rate: 0.95 },
|
||
{ damage: 680, range: 195, rate: 1.0 },
|
||
],
|
||
hitsFlying: false,
|
||
projectileSpeed: 300,
|
||
splash: [42, 48, 56, 64, 70, 76, 84, 92, 100],
|
||
dotDps: [0, 0, 0, 10, 14, 20, 26, 34, 45],
|
||
dotDuration: [0, 0, 0, 2, 2, 2.5, 2.5, 3, 3],
|
||
dotColor: '#ff9c40',
|
||
flak: [false, false, false, false, false, false, true, true, true],
|
||
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, 260, 420, 700, 1100, 1800, 3000],
|
||
levels: [
|
||
{ damage: 5, range: 95, rate: 0.9 },
|
||
{ damage: 9, range: 105, rate: 1.0 },
|
||
{ damage: 15, range: 118, rate: 1.1 },
|
||
{ damage: 22, range: 128, rate: 1.15 },
|
||
{ damage: 30, range: 138, rate: 1.2 },
|
||
{ damage: 40, range: 148, rate: 1.25 },
|
||
{ damage: 60, range: 158, rate: 1.3 },
|
||
{ damage: 80, range: 170, rate: 1.35 },
|
||
{ damage: 105, range: 182, rate: 1.4 },
|
||
],
|
||
hitsFlying: true,
|
||
slowFactor: [0.55, 0.45, 0.35, 0.32, 0.3, 0.28, 0.25, 0.22, 0.2],
|
||
slowDuration: [1.5, 1.9, 2.3, 2.5, 2.7, 2.9, 3.1, 3.3, 3.5],
|
||
freezeDuration: [0, 0, 0, 0.7, 0.9, 1.1, 1.3, 1.5, 1.8],
|
||
color: '#4d8fb5',
|
||
accent: '#aef0ff',
|
||
hotkey: '3',
|
||
},
|
||
tesla: {
|
||
kind: 'tesla',
|
||
name: 'Teslaturm',
|
||
icon: '⚡',
|
||
desc: 'Kettenblitz springt auf mehrere Gegner über.',
|
||
cost: 130,
|
||
upgradeCost: [120, 200, 400, 650, 1000, 1700, 2800, 4500],
|
||
levels: [
|
||
{ damage: 22, range: 105, rate: 1.0 },
|
||
{ damage: 38, range: 115, rate: 1.1 },
|
||
{ damage: 66, range: 128, rate: 1.2 },
|
||
{ damage: 95, range: 138, rate: 1.3 },
|
||
{ damage: 125, range: 145, rate: 1.4 },
|
||
{ damage: 160, range: 152, rate: 1.5 },
|
||
{ damage: 230, range: 160, rate: 1.6 },
|
||
{ damage: 300, range: 168, rate: 1.7 },
|
||
{ damage: 400, range: 175, rate: 1.8 },
|
||
],
|
||
hitsFlying: true,
|
||
chain: [3, 4, 5, 6, 6, 7, 7, 8, 9],
|
||
chainRange: [100, 100, 100, 110, 110, 115, 120, 125, 130],
|
||
stunDuration: [0, 0, 0, 0.2, 0.25, 0.3, 0.35, 0.4, 0.5],
|
||
stormEvery: [0, 0, 0, 0, 0, 0, 5, 5, 4],
|
||
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, 500, 800, 1300, 2100, 3400, 5500],
|
||
levels: [
|
||
{ damage: 60, range: 170, rate: 0.5 },
|
||
{ damage: 105, range: 185, rate: 0.55 },
|
||
{ damage: 185, range: 205, rate: 0.6 },
|
||
{ damage: 170, range: 215, rate: 0.6 },
|
||
{ damage: 230, range: 225, rate: 0.65 },
|
||
{ damage: 300, range: 235, rate: 0.7 },
|
||
{ damage: 420, range: 250, rate: 0.75 },
|
||
{ damage: 560, range: 265, rate: 0.8 },
|
||
{ damage: 750, range: 280, rate: 0.85 },
|
||
],
|
||
hitsFlying: true,
|
||
prism: [0, 0, 0, 2, 2, 3, 0, 0, 0],
|
||
pierce: [false, false, false, false, false, false, true, true, true],
|
||
color: '#b0483f',
|
||
accent: '#ff8a7a',
|
||
hotkey: '5',
|
||
},
|
||
}
|
||
|
||
/** max tower level */
|
||
export const MAX_TOWER_LEVEL = 9
|
||
|
||
/** tier of a tower level: 0 = yellow (1-3), 1 = orange (4-6), 2 = blue (7-9) */
|
||
export function towerTier(level: number): number {
|
||
return Math.min(2, Math.floor((level - 1) / 3))
|
||
}
|
||
|
||
/** tier colors for level pips, stars and auras */
|
||
export const TIER_COLORS = ['#ffd23e', '#ff9c40', '#4da3ff']
|
||
|
||
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',
|
||
},
|
||
// --- 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 (steeper curve: late waves get noticeably tougher) */
|
||
export function waveHpScale(n: number): number {
|
||
const m = n - 1
|
||
return 1 + 0.16 * m + 0.025 * 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 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)],
|
||
/* 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), 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('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 30 */
|
||
export function endlessWave(n: number): WaveGroup[] {
|
||
const m = n - 30
|
||
const groups: WaveGroup[] = []
|
||
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
|
||
}
|
||
|
||
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',
|
||
brute: '#b0763a',
|
||
phantom: '#a9c6de',
|
||
scorpion: '#c2503d',
|
||
golem: '#8a8f7a',
|
||
dragon: '#9b3a8f',
|
||
}
|
||
|
||
export const ENEMY_ICONS: Record<EnemyKind, string> = {
|
||
normal: '🟢',
|
||
fast: '🟡',
|
||
tank: '⬜',
|
||
flyer: '🟣',
|
||
boss: '🔴',
|
||
brute: '🟤',
|
||
phantom: '⚪',
|
||
scorpion: '🦂',
|
||
golem: '🗿',
|
||
dragon: '🐲',
|
||
}
|