feat: add 9-level tower evolution system with distinct tier abilities

Extend all 5 towers to 9 upgrade levels, divided into 3 visual and
functional evolution tiers:
- Tier 1 (L1-3, Yellow): Base stats (damage, range, rate).
- Tier 2 (L4-6, Orange): First major ability evolution with pulsing orange aura.
- Tier 3 (L7-9, Blue): Final devastating evolution with blue aura.

Evolutions per tower:
- Arrow Tower:
  * L4-6 (Orange): Multishot firing 2-3 arrows simultaneously at distinct targets.
  * L7-9 (Blue): 4-arrow multishot + Poison DoT (up to 28 dmg/s).
- Cannon:
  * L4-6 (Orange): Incendiary shells applying Burn DoT in splash radius.
  * L7-9 (Blue): Flak shells hitting flying units with full splash + burn damage.
- Frost Tower:
  * L4-6 (Orange): Permafrost - every 3rd pulse completely freezes enemies (speed = 0).
  * L7-9 (Blue): Shatter - frozen enemies receive +50% damage from all sources.
- Tesla Tower:
  * L4-6 (Orange): Chain lightning applies stun on each hit.
  * L7-9 (Blue): Lightning Storm - every 4th shot strikes and stuns ALL enemies in range.
- Laser Tower:
  * L4-6 (Orange): Prism refraction splitting the beam onto 2-3 secondary targets.
  * L7-9 (Blue): Piercing beam penetrating all enemies in a line up to 280px range.

Engine & Renderer updates:
- Added DoT tick handling (poison/burn) and freeze/stun status effects.
- Added visual indicators for frozen enemies (ice spikes) and DoT particles.
- Added 3-slot tier-colored pips on tower base plates + pulsating evolution auras.
- Updated TowerPanel with 9-star tier display (★★★ yellow, ★★★ orange, ★★★ blue)
  and dynamic special ability descriptions.

Testing & Validation:
- Added `scripts/test-evolution.mts` covering all 10 evolved mechanics (all green).
- Verified lockstep multiplayer determinism via `scripts/test-mp.mts`.
- Verified solo campaign balance preservation via `scripts/sim.mts`.
- Full TypeScript typecheck and production build passing.
This commit is contained in:
Tronax 2026-08-16 13:54:09 +02:00
parent ef75203db7
commit 4f8400c480
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
8 changed files with 518 additions and 98 deletions

View file

@ -115,18 +115,27 @@ Verteidige deine Basis gegen 20 handgetunte Wellen (Leicht: 30 Leben, Normal: 20
--- ---
## Türme ## 🌟 9-Level-Evolutionssystem
| Turm | Kosten | Upgrade 2/3 | Besonderheit | Jeder Turm hat **9 Ausbaustufen**, aufgeteilt in **3 Evolutions-Stufen**:
| Tier | Level | Farbe | Bedeutung |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 🏹 Bogenturm | 50 🪙 | 60 / 110 🪙 | Günstig, schnelle Einzelschüsse, trifft Luft | | **Basis** | 13 | 🟡 Gelb | Höherer Grundschaden, Reichweite & Feuerrate |
| 🧨 Kanone | 110 🪙 | 100 / 180 🪙 | Flächenschaden, trifft **keine** Flieger | | **Evolution I** | 46 | 🟠 Orange | Neue Spezialfähigkeit schaltet sich frei |
| ❄️ Eisturm | 80 🪙 | 70 / 130 🪙 | Verlangsamt alle Gegner im Umkreis um bis zu 65 % | | **Evolution II** | 79 | 🔵 Blau | Finale Evolutionsstufe mit verheerenden Effekten |
| ⚡ Teslaturm | 130 🪙 | 120 / 200 🪙 | Kettenblitz springt auf bis zu 5 Gegner über |
| 💫 Laserturm | 160 🪙 | 150 / 260 🪙 | Soforttreffer, maximale Reichweite & Durchschlag |
- **Verkauf:** 70 % der Gesamtinvestition (Kauf + Upgrades) werden erstattet. ### Die Evolutionen im Detail
- **Zielmodi:** Erster / Letzter / Stärkster / Nächster.
| Turm | 🟡 Basis (L13) | 🟠 Evolution I (L46) | 🔵 Evolution II (L79) |
| --- | --- | --- | --- |
| 🏹 **Bogenturm** | Schneller Einzelschuss | **Mehrfachschuss:** Feuert 23 Pfeile gleichzeitig auf verschiedene Ziele | **Giftpfeile + 4-Fach:** Bis zu 4 Pfeile + starker Gift-DoT (28 Schaden/s) |
| 🧨 **Kanone** | Flächenschaden | **Brandgeschosse:** Hinterlässt brennenden DoT an allen getroffenen Gegnern | **Flak-Geschütz:** Trifft ab L7 auch **Flugeinheiten** mit voller Explosionskraft! |
| ❄️ **Eisturm** | Verlangsamender Puls | **Permafrost:** Jeder 3. Puls **friert alle Gegner komplett ein** (Bewegung = 0) | **Zerbrechlichkeit:** Eingefrorene Ziele nehmen **+50 % Schaden aus ALLEN Quellen**! |
| ⚡ **Teslaturm** | Kettenblitz | **Betäubungs-Schock:** Kettenblitze betäuben getroffene Ziele kurzzeitig | **Gewittersturm:** Jeder 4. Schuss schlägt bei **ALLEN Gegnern in Reichweite** gleichzeitig ein! |
| 💫 **Laserturm** | Soforttreffer | **Prisma-Brechung:** Der Laserstrahl bricht und springt auf 23 Nebenziele über | **Durchschlag:** Der Strahl durchdringt ALLES in einer Linie bis zum Rand der Reichweite |
Türme ab Level 4 erhalten im Spiel eine pulsierende **Evolutions-Aura** (orange ab L4, blau ab L7) und die Sterne im Turm-Panel zeigen die jeweilige Tier-Farbe.
--- ---

View file

@ -5,8 +5,12 @@
* Run: npm run sim * Run: npm run sim
*/ */
import { GameEngine } from '../src/game/engine.ts' import { GameEngine } from '../src/game/engine.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 */
const SIM_MAX_LEVEL = 3
const eng = new GameEngine() const eng = new GameEngine()
eng.startGame('normal') eng.startGame('normal')
@ -122,20 +126,12 @@ function bot(): void {
} }
function towerCost(kind: TowerKind): number { function towerCost(kind: TowerKind): number {
const costs: Record<TowerKind, number> = { arrow: 50, cannon: 110, frost: 80, tesla: 130, laser: 160 } return TOWERS[kind].cost
return costs[kind]
} }
function upgradeCostOf(kind: TowerKind, level: number): number | null { function upgradeCostOf(kind: TowerKind, level: number): number | null {
const up: Record<TowerKind, [number, number]> = { if (level >= SIM_MAX_LEVEL) return null
arrow: [60, 110], return TOWERS[kind].upgradeCost[level - 1]
cannon: [100, 180],
frost: [70, 130],
tesla: [120, 200],
laser: [150, 260],
}
if (level >= 3) return null
return up[kind][level - 1]
} }
let lastWave = 0 let lastWave = 0

133
scripts/test-evolution.mts Normal file
View file

@ -0,0 +1,133 @@
/**
* Evolution system test: each mechanic is verified on an isolated engine so
* only the tower under test can kill the test enemies.
*/
import { GameEngine } from '../src/game/engine.ts'
import { TOWERS } from '../src/game/config.ts'
import type { Tower, TowerKind, Enemy } from '../src/game/types.ts'
let failures = 0
const ok = (label: string, cond: boolean): void => {
console.log(`${cond ? '✓' : '✗ FEHLER'}: ${label}`)
if (!cond) failures++
}
interface Rig {
eng: GameEngine
tower: Tower
spawn(x: number, y: number, kind?: 'normal' | 'flyer' | 'boss'): Enemy
}
/** fresh engine with one max-level tower in the center */
function rig(kind: TowerKind, targetLevel = 9): Rig {
const eng = new GameEngine()
eng.startGame('easy')
eng.money = 1_000_000
eng.startWave(true)
eng.placeTower(kind, 8, 5)
const tower = eng.towerAt(8, 5)!
while (tower.level < targetLevel) eng.upgradeTower(tower)
const spawn = (x: number, y: number, k: 'normal' | 'flyer' | 'boss' = 'normal'): Enemy => {
;(eng as unknown as { spawnEnemy: (kk: 'normal' | 'flyer' | 'boss') => void }).spawnEnemy(k)
const e = eng.enemies[eng.enemies.length - 1]
e.x = x
e.y = y
e.speedBase = 0
e.wp = 1
return e
}
return { eng, tower, spawn }
}
function forceFire(r: Rig, ticks = 12): void {
for (let i = 0; i < ticks; i++) {
r.tower.cooldown = 0
r.eng.update(1 / 30)
}
}
const CX = 8 * 48 + 24
const CY = 5 * 48 + 24
// ---------- arrow: multishot + poison ----------
{
const r = rig('arrow')
ok('Bogenturm L9', r.tower.level === 9)
const targets = [r.spawn(CX, CY - 30), r.spawn(CX, CY), r.spawn(CX, CY + 30), r.spawn(CX, CY + 60)]
let arrows = 0
for (let i = 0; i < 12; i++) {
r.tower.cooldown = 0
r.eng.update(1 / 30)
arrows = Math.max(arrows, r.eng.projectiles.filter((p) => p.kind === 'arrow').length)
}
ok(`Multishot: mehrere Pfeile gleichzeitig (${arrows})`, arrows >= 3)
ok('Pfeile treffen (Schaden)', targets.every((e) => e.hp < e.maxHp || e.dead))
ok('Gift-DoT wirkt', r.eng.enemies.some((e) => e.dotDps === 28 && e.dotUntil > r.eng.time) || targets.every((e) => e.dead))
}
// ---------- frost: freeze + shatter ----------
{
const r = rig('frost')
ok('Eisturm L9', r.tower.level === 9)
const e = r.spawn(CX, CY - 20, 'boss')
for (let i = 0; i < 3; i++) {
r.tower.cooldown = 0
r.eng.update(1 / 30)
}
ok('Einfrieren nach 3 Pulsen (Faktor 0)', e.slowFactor === 0 && !e.dead)
const hp = e.hp
r.eng.damageEnemy(e, 100)
ok('Zerbrechlich: +50% Schaden an Eingefrorenen', Math.abs(hp - e.hp - 150) < 1)
}
// ---------- tesla: stun + storm ----------
{
const r = rig('tesla')
ok('Tesla L9', r.tower.level === 9)
const group = [r.spawn(CX - 24, CY), r.spawn(CX + 24, CY), r.spawn(CX, CY + 24)]
r.tower.counter = 3 // nächster Schuss = Gewitter (jeder 4.)
r.tower.cooldown = 0
r.eng.update(1 / 30)
const stunned = group.filter((e) => e.slowFactor === 0)
ok(`Gewitter trifft + betäubt mehrere (${stunned.length}/3)`, stunned.length >= 2)
}
// ---------- laser: pierce ----------
{
const r = rig('laser')
ok('Laser L9', r.tower.level === 9)
const line = [r.spawn(CX + 40, CY), r.spawn(CX + 75, CY), r.spawn(CX + 110, CY), r.spawn(CX + 145, CY)]
forceFire(r)
ok('Durchschlag trifft alle in der Linie', line.every((e) => e.hp < e.maxHp || e.dead))
}
// ---------- laser: prism (L4-6 only) ----------
{
const r = rig('laser', 5)
ok('Laser L5 (Prisma-Stufe)', r.tower.level === 5)
r.spawn(CX + 40, CY)
const b = r.spawn(CX + 60, CY + 10)
const c = r.spawn(CX + 80, CY - 10)
forceFire(r)
ok('Prisma: Nebenziele nehmen Schaden', (b.hp < b.maxHp || b.dead) && (c.hp < c.maxHp || c.dead))
}
// ---------- cannon: flak + burn ----------
{
const r = rig('cannon')
ok('Kanone L9', r.tower.level === 9)
ok('Flak-Upgrade aktiv (trifft Flieger)', TOWERS.cannon.flak![8] === true)
const flyer = r.spawn(CX + 60, CY, 'flyer')
forceFire(r)
ok('Flak: Kanone beschädigt Flieger', flyer.hp < flyer.maxHp || flyer.dead)
const boss = r.spawn(CX + 60, CY, 'boss')
forceFire(r)
ok('Brand-DoT wirkt', boss.dotDps > 0 && boss.dotColor === '#ff9c40')
}
console.log('---')
if (failures === 0) console.log('Alle Evolutions-Mechaniken funktionieren.')
else {
console.log(`${failures} FEHLER!`)
process.exit(1)
}

View file

@ -23,8 +23,13 @@ function canAct(): boolean {
<div class="title"> <div class="title">
<div class="name">{{ store.selected.name }}</div> <div class="name">{{ store.selected.name }}</div>
<div class="level"> <div class="level">
<span v-for="i in 3" :key="i" class="star" :class="{ on: i <= store.selected.level }"></span> <span
<span class="lvl-text">Level {{ store.selected.level }}</span> v-for="i in 9"
:key="i"
class="star"
:class="['t' + Math.floor((i - 1) / 3), { on: i <= store.selected.level }]"
></span>
<span class="lvl-text">Level {{ store.selected.level }}/9</span>
</div> </div>
</div> </div>
<button class="close" title="Schließen (Esc)" @click="engine.cancelMode()"></button> <button class="close" title="Schließen (Esc)" @click="engine.cancelMode()"></button>
@ -60,7 +65,7 @@ function canAct(): boolean {
:disabled="store.selected.maxLevel || store.money < (store.selected.upgradeCost ?? 0) || !canAct()" :disabled="store.selected.maxLevel || store.money < (store.selected.upgradeCost ?? 0) || !canAct()"
@click="uiUpgradeSelected()" @click="uiUpgradeSelected()"
> >
<template v-if="store.selected.maxLevel">Max-Level</template> <template v-if="store.selected.maxLevel">Max-Level (9)</template>
<template v-else> Aufrüsten · 🪙 {{ store.selected.upgradeCost }}</template> <template v-else> Aufrüsten · 🪙 {{ store.selected.upgradeCost }}</template>
</button> </button>
<button class="btn sell" :disabled="!canAct()" @click="uiSellSelected()"> <button class="btn sell" :disabled="!canAct()" @click="uiSellSelected()">
@ -109,9 +114,9 @@ function canAct(): boolean {
color: #3a4450; color: #3a4450;
font-size: 12px; font-size: 12px;
} }
.star.on { .star.on.t0 { color: #ffd23e; }
color: #ffd23e; .star.on.t1 { color: #ff9c40; }
} .star.on.t2 { color: #4da3ff; }
.lvl-text { .lvl-text {
margin-left: 5px; margin-left: 5px;
font-size: 11px; font-size: 11px;

View file

@ -53,14 +53,24 @@ export const TOWERS: Record<TowerKind, TowerDef> = {
icon: '🏹', icon: '🏹',
desc: 'Günstiger Standardturm mit schnellen Einzelschüssen.', desc: 'Günstiger Standardturm mit schnellen Einzelschüssen.',
cost: 50, cost: 50,
upgradeCost: [60, 110], upgradeCost: [60, 110, 260, 420, 700, 1200, 2000, 3400],
levels: [ levels: [
{ damage: 12, range: 110, rate: 1.7 }, { damage: 12, range: 110, rate: 1.7 },
{ damage: 22, range: 120, rate: 2.0 }, { damage: 22, range: 120, rate: 2.0 },
{ damage: 38, range: 132, rate: 2.4 }, { 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, hitsFlying: true,
projectileSpeed: 460, 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', color: '#9a6a33',
accent: '#e8c27a', accent: '#e8c27a',
hotkey: '1', hotkey: '1',
@ -71,15 +81,25 @@ export const TOWERS: Record<TowerKind, TowerDef> = {
icon: '🧨', icon: '🧨',
desc: 'Flächenschaden durch Explosion trifft aber keine Flieger.', desc: 'Flächenschaden durch Explosion trifft aber keine Flieger.',
cost: 110, cost: 110,
upgradeCost: [100, 180], upgradeCost: [100, 180, 380, 600, 950, 1600, 2600, 4200],
levels: [ levels: [
{ damage: 28, range: 118, rate: 0.55 }, { damage: 28, range: 118, rate: 0.55 },
{ damage: 50, range: 128, rate: 0.62 }, { damage: 50, range: 128, rate: 0.62 },
{ damage: 92, range: 140, rate: 0.7 }, { 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, hitsFlying: false,
projectileSpeed: 300, projectileSpeed: 300,
splash: [42, 48, 56], 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', color: '#57616e',
accent: '#ff9c40', accent: '#ff9c40',
hotkey: '2', hotkey: '2',
@ -90,15 +110,22 @@ export const TOWERS: Record<TowerKind, TowerDef> = {
icon: '❄️', icon: '❄️',
desc: 'Frostpuls verlangsamt ALLE Gegner in Reichweite (auch Flieger).', desc: 'Frostpuls verlangsamt ALLE Gegner in Reichweite (auch Flieger).',
cost: 80, cost: 80,
upgradeCost: [70, 130], upgradeCost: [70, 130, 260, 420, 700, 1100, 1800, 3000],
levels: [ levels: [
{ damage: 5, range: 95, rate: 0.9 }, { damage: 5, range: 95, rate: 0.9 },
{ damage: 9, range: 105, rate: 1.0 }, { damage: 9, range: 105, rate: 1.0 },
{ damage: 15, range: 118, rate: 1.1 }, { 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, hitsFlying: true,
slowFactor: [0.55, 0.45, 0.35], 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], 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', color: '#4d8fb5',
accent: '#aef0ff', accent: '#aef0ff',
hotkey: '3', hotkey: '3',
@ -109,15 +136,23 @@ export const TOWERS: Record<TowerKind, TowerDef> = {
icon: '⚡', icon: '⚡',
desc: 'Kettenblitz springt auf mehrere Gegner über.', desc: 'Kettenblitz springt auf mehrere Gegner über.',
cost: 130, cost: 130,
upgradeCost: [120, 200], upgradeCost: [120, 200, 400, 650, 1000, 1700, 2800, 4500],
levels: [ levels: [
{ damage: 22, range: 105, rate: 1.0 }, { damage: 22, range: 105, rate: 1.0 },
{ damage: 38, range: 115, rate: 1.1 }, { damage: 38, range: 115, rate: 1.1 },
{ damage: 66, range: 128, rate: 1.2 }, { 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, hitsFlying: true,
chain: [3, 4, 5], chain: [3, 4, 5, 6, 6, 7, 7, 8, 9],
chainRange: 100, 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', color: '#6a5fb0',
accent: '#c8b4ff', accent: '#c8b4ff',
hotkey: '4', hotkey: '4',
@ -128,19 +163,38 @@ export const TOWERS: Record<TowerKind, TowerDef> = {
icon: '💫', icon: '💫',
desc: 'Soforttreffer mit hoher Reichweite und hohem Schaden.', desc: 'Soforttreffer mit hoher Reichweite und hohem Schaden.',
cost: 160, cost: 160,
upgradeCost: [150, 260], upgradeCost: [150, 260, 500, 800, 1300, 2100, 3400, 5500],
levels: [ levels: [
{ damage: 60, range: 170, rate: 0.5 }, { damage: 60, range: 170, rate: 0.5 },
{ damage: 105, range: 185, rate: 0.55 }, { damage: 105, range: 185, rate: 0.55 },
{ damage: 185, range: 205, rate: 0.6 }, { 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, hitsFlying: true,
prism: [0, 0, 0, 2, 2, 3, 0, 0, 0],
pierce: [false, false, false, false, false, false, true, true, true],
color: '#b0483f', color: '#b0483f',
accent: '#ff8a7a', accent: '#ff8a7a',
hotkey: '5', 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> = { export const ENEMIES: Record<EnemyKind, EnemyDef> = {
normal: { normal: {
kind: 'normal', kind: 'normal',

View file

@ -539,6 +539,10 @@ export class GameEngine {
traveled: 0, traveled: 0,
slowUntil: 0, slowUntil: 0,
slowFactor: 1, slowFactor: 1,
dotDps: 0,
dotUntil: 0,
dotSourceId: -1,
dotColor: '#7ddc4f',
dead: false, dead: false,
escaped: false, escaped: false,
flash: 0, flash: 0,
@ -551,6 +555,12 @@ export class GameEngine {
private updateEnemy(e: Enemy, dt: number): void { private updateEnemy(e: Enemy, dt: number): void {
if (e.dead || e.escaped) return if (e.dead || e.escaped) return
e.flash = Math.max(0, e.flash - dt * 6) e.flash = Math.max(0, e.flash - dt * 6)
// damage over time (poison/burn)
if (this.time < e.dotUntil && e.dotDps > 0) {
const src = this.towers.find((t) => t.id === e.dotSourceId)
this.damageEnemy(e, e.dotDps * dt, src, true)
if (e.dead) return
}
const slowed = this.time < e.slowUntil const slowed = this.time < e.slowUntil
const speed = e.speedBase * (slowed ? e.slowFactor : 1) const speed = e.speedBase * (slowed ? e.slowFactor : 1)
const path = e.flying ? this.flyPathPx : this.pathPx const path = e.flying ? this.flyPathPx : this.pathPx
@ -626,6 +636,7 @@ export class GameEngine {
invested: def.cost, invested: def.cost,
recoil: 0, recoil: 0,
pulse: 0, pulse: 0,
counter: 0,
targetId: null, targetId: null,
owner, owner,
}) })
@ -661,7 +672,7 @@ export class GameEngine {
upgradeTower(t: Tower): void { upgradeTower(t: Tower): void {
const def = TOWERS[t.kind] const def = TOWERS[t.kind]
if (t.level >= 3) return if (t.level >= 9) return
const cost = def.upgradeCost[t.level - 1] const cost = def.upgradeCost[t.level - 1]
if (this.money < cost) { if (this.money < cost) {
this.sfx('error') this.sfx('error')
@ -669,7 +680,7 @@ export class GameEngine {
} }
this.money -= cost this.money -= cost
t.invested += cost t.invested += cost
t.level = (t.level + 1) as Tower['level'] t.level = Math.min(9, t.level + 1)
this.sfx('upgrade') this.sfx('upgrade')
this.fx({ type: 'ring', x: t.x, y: t.y, r0: 8, r1: 52, life: 0.4, max: 0.4, color: '#ffd23e', width: 3 }) this.fx({ type: 'ring', x: t.x, y: t.y, r0: 8, r1: 52, life: 0.4, max: 0.4, color: '#ffd23e', width: 3 })
this.fx({ type: 'text', x: t.x, y: t.y - 26, vy: -30, life: 1, max: 1, str: 'Level ' + t.level, color: '#ffd23e', size: 13 }) this.fx({ type: 'text', x: t.x, y: t.y - 26, vy: -30, life: 1, max: 1, str: 'Level ' + t.level, color: '#ffd23e', size: 13 })
@ -706,14 +717,17 @@ export class GameEngine {
return TOWERS[t.kind].levels[t.level - 1] return TOWERS[t.kind].levels[t.level - 1]
} }
private acquireTarget(t: Tower): Enemy | undefined { private canHit(t: Tower, e: Enemy): boolean {
const def = TOWERS[t.kind] const def = TOWERS[t.kind]
return def.hitsFlying || !!def.flak?.[t.level - 1] || !e.flying
}
private acquireTargets(t: Tower, n: number): Enemy[] {
const stats = this.towerStats(t) const stats = this.towerStats(t)
let best: Enemy | undefined const scored: { e: Enemy; s: number }[] = []
let bestScore = -Infinity
for (const e of this.enemies) { for (const e of this.enemies) {
if (e.dead || e.escaped) continue if (e.dead || e.escaped) continue
if (e.flying && !def.hitsFlying) continue if (!this.canHit(t, e)) continue
const d = dist(t.x, t.y, e.x, e.y) const d = dist(t.x, t.y, e.x, e.y)
if (d > stats.range + e.r) continue if (d > stats.range + e.r) continue
let s: number let s: number
@ -731,12 +745,29 @@ export class GameEngine {
s = -d s = -d
break break
} }
if (s > bestScore) { scored.push({ e, s })
bestScore = s
best = e
}
} }
return best scored.sort((a, b) => b.s - a.s)
return scored.slice(0, n).map((x) => x.e)
}
private acquireTarget(t: Tower): Enemy | undefined {
return this.acquireTargets(t, 1)[0]
}
/** freeze/stun: sets speed multiplier to 0 for a duration (reuses slow system) */
private applyFreeze(e: Enemy, duration: number): void {
e.slowFactor = 0
e.slowUntil = Math.max(e.slowUntil, this.time + duration)
}
/** poison/burn: damage over time, stronger effects replace weaker ones */
private applyDot(e: Enemy, dps: number, duration: number, color: string, sourceId: number): void {
if (this.time < e.dotUntil && e.dotDps > dps) return
e.dotDps = dps
e.dotUntil = this.time + duration
e.dotSourceId = sourceId
e.dotColor = color
} }
private updateTower(t: Tower, dt: number): void { private updateTower(t: Tower, dt: number): void {
@ -759,16 +790,33 @@ export class GameEngine {
if (any) { if (any) {
t.cooldown = 1 / stats.rate t.cooldown = 1 / stats.rate
t.pulse = 1 t.pulse = 1
this.fx({ type: 'ring', x: t.x, y: t.y, r0: 12, r1: stats.range, life: 0.45, max: 0.45, color: '#aef0ff', width: 3 }) t.counter++
const freezeDur = def.freezeDuration?.[t.level - 1] ?? 0
const doFreeze = freezeDur > 0 && t.counter % 3 === 0
this.fx({
type: 'ring',
x: t.x,
y: t.y,
r0: 12,
r1: stats.range,
life: 0.45,
max: 0.45,
color: doFreeze ? '#e8f8ff' : '#aef0ff',
width: doFreeze ? 5 : 3,
})
this.sfx('pulse') this.sfx('pulse')
for (const e of this.enemies) { for (const e of this.enemies) {
if (e.dead || e.escaped) continue if (e.dead || e.escaped) continue
if (dist(t.x, t.y, e.x, e.y) <= stats.range + e.r) { if (dist(t.x, t.y, e.x, e.y) <= stats.range + e.r) {
const sf = def.slowFactor![t.level - 1] if (doFreeze) {
const sd = def.slowDuration![t.level - 1] this.applyFreeze(e, freezeDur)
if (this.time >= e.slowUntil || sf <= e.slowFactor) { } else {
e.slowFactor = sf const sf = def.slowFactor![t.level - 1]
e.slowUntil = this.time + sd const sd = def.slowDuration![t.level - 1]
if (this.time >= e.slowUntil || sf <= e.slowFactor) {
e.slowFactor = sf
e.slowUntil = this.time + sd
}
} }
this.damageEnemy(e, stats.damage, t) this.damageEnemy(e, stats.damage, t)
} }
@ -781,7 +829,7 @@ export class GameEngine {
// validate / reacquire target // validate / reacquire target
let target: Enemy | undefined = let target: Enemy | undefined =
t.targetId !== null ? this.enemies.find((e) => e.id === t.targetId && !e.dead && !e.escaped) : undefined t.targetId !== null ? this.enemies.find((e) => e.id === t.targetId && !e.dead && !e.escaped) : undefined
if (target && (dist(t.x, t.y, target.x, target.y) > stats.range + target.r || (target.flying && !def.hitsFlying))) { if (target && (dist(t.x, t.y, target.x, target.y) > stats.range + target.r || !this.canHit(t, target))) {
target = undefined target = undefined
} }
if (!target) { if (!target) {
@ -808,21 +856,26 @@ export class GameEngine {
switch (t.kind) { switch (t.kind) {
case 'arrow': { case 'arrow': {
this.projectiles.push({ const multi = def.multi?.[t.level - 1] ?? 1
kind: 'arrow', const targets = multi > 1 ? this.acquireTargets(t, multi) : [target]
x: bx, if (targets.length === 0) targets.push(target)
y: by, for (const tg of targets) {
angle: t.angle, this.projectiles.push({
speed: def.projectileSpeed!, kind: 'arrow',
damage: stats.damage, x: bx,
splash: 0, y: by,
target, angle: angleTo(bx, by, tg.x, tg.y),
tx: target.x, speed: def.projectileSpeed!,
ty: target.y, damage: stats.damage,
life: 3, splash: 0,
trail: 0, target: tg,
ownerId: t.id, tx: tg.x,
}) ty: tg.y,
life: 3,
trail: 0,
ownerId: t.id,
})
}
this.sfx('shootArrow') this.sfx('shootArrow')
break break
} }
@ -861,14 +914,40 @@ export class GameEngine {
break break
} }
case 'tesla': { case 'tesla': {
const stunDur = def.stunDuration?.[t.level - 1] ?? 0
const stormEvery = def.stormEvery?.[t.level - 1] ?? 0
t.counter++
// GEWITTER (L7+): every Nth shot hits ALL enemies in range
if (stormEvery > 0 && t.counter % stormEvery === 0) {
let struck = 0
for (const e of this.enemies) {
if (e.dead || e.escaped) continue
if (!this.canHit(t, e)) continue
if (dist(t.x, t.y, e.x, e.y) > stats.range + e.r) continue
this.damageEnemy(e, stats.damage, t)
if (stunDur > 0) this.applyFreeze(e, stunDur)
this.fx({ type: 'bolt', pts: [{ x: t.x, y: t.y - 16 }, { x: e.x, y: e.y }], life: 0.2, max: 0.2, color: '#ffffff' })
struck++
}
if (struck > 0) {
this.fx({ type: 'ring', x: t.x, y: t.y - 16, r0: 10, r1: stats.range, life: 0.35, max: 0.35, color: def.accent, width: 4 })
this.shake = Math.max(this.shake, 0.2)
}
this.sfx('tesla')
break
}
const chainCount = def.chain![t.level - 1] const chainCount = def.chain![t.level - 1]
const chainRange = def.chainRange![t.level - 1]
const hit: Enemy[] = [target] const hit: Enemy[] = [target]
const dmg = stats.damage const dmg = stats.damage
this.damageEnemy(target, dmg, t) this.damageEnemy(target, dmg, t)
if (stunDur > 0) this.applyFreeze(target, stunDur)
let prev = target let prev = target
while (hit.length < chainCount) { while (hit.length < chainCount) {
let next: Enemy | undefined let next: Enemy | undefined
let nd = def.chainRange! let nd = chainRange
for (const e of this.enemies) { for (const e of this.enemies) {
if (e.dead || e.escaped || hit.includes(e)) continue if (e.dead || e.escaped || hit.includes(e)) continue
const d = dist(prev.x, prev.y, e.x, e.y) const d = dist(prev.x, prev.y, e.x, e.y)
@ -880,6 +959,7 @@ export class GameEngine {
if (!next) break if (!next) break
hit.push(next) hit.push(next)
this.damageEnemy(next, dmg * Math.pow(0.75, hit.length - 1), t) this.damageEnemy(next, dmg * Math.pow(0.75, hit.length - 1), t)
if (stunDur > 0) this.applyFreeze(next, stunDur)
prev = next prev = next
} }
const pts: Pt[] = [{ x: t.x, y: t.y - 14 }, ...hit.map((e) => ({ x: e.x, y: e.y }))] const pts: Pt[] = [{ x: t.x, y: t.y - 14 }, ...hit.map((e) => ({ x: e.x, y: e.y }))]
@ -888,6 +968,31 @@ export class GameEngine {
break break
} }
case 'laser': { case 'laser': {
// DURCHSCHLAG (L7+): the beam hits everything along its line
if (def.pierce?.[t.level - 1]) {
const ang = angleTo(t.x, t.y, target.x, target.y)
const dx = Math.cos(ang)
const dy = Math.sin(ang)
const ex2 = t.x + dx * stats.range
const ey2 = t.y + dy * stats.range
for (const e of this.enemies) {
if (e.dead || e.escaped) continue
if (!this.canHit(t, e)) continue
const px = e.x - t.x
const py = e.y - t.y
const proj = px * dx + py * dy
if (proj < 0 || proj > stats.range + e.r) continue
const perp = Math.abs(px * dy - py * dx)
if (perp <= 14 + e.r) {
this.damageEnemy(e, stats.damage, t)
this.fx({ type: 'particle', x: e.x, y: e.y, vx: dx * 70, vy: dy * 70 - 20, life: 0.3, max: 0.3, size: 2.5, color: '#ffb0a0', grav: 0 })
}
}
this.fx({ type: 'beam', x1: bx, y1: by, x2: ex2, y2: ey2, life: 0.2, max: 0.2, color: def.accent })
this.sfx('laser')
break
}
this.damageEnemy(target, stats.damage, t) this.damageEnemy(target, stats.damage, t)
this.fx({ type: 'beam', x1: bx, y1: by, x2: target.x, y2: target.y, life: 0.18, max: 0.18, color: def.accent }) this.fx({ type: 'beam', x1: bx, y1: by, x2: target.x, y2: target.y, life: 0.18, max: 0.18, color: def.accent })
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
@ -905,6 +1010,26 @@ export class GameEngine {
grav: 0, grav: 0,
}) })
} }
// PRISMA (L4-6): beam refracts onto nearby enemies
const prism = def.prism?.[t.level - 1] ?? 0
if (prism > 0) {
const others = this.enemies
.filter(
(e) =>
!e.dead &&
!e.escaped &&
e !== target &&
this.canHit(t, e) &&
dist(t.x, t.y, e.x, e.y) <= stats.range + e.r,
)
.sort((a, b) => dist(t.x, t.y, a.x, a.y) - dist(t.x, t.y, b.x, b.y))
.slice(0, prism)
for (const o of others) {
this.damageEnemy(o, stats.damage * 0.65, t)
this.fx({ type: 'beam', x1: target.x, y1: target.y, x2: o.x, y2: o.y, life: 0.16, max: 0.16, color: def.accent })
}
}
this.sfx('laser') this.sfx('laser')
break break
} }
@ -913,11 +1038,13 @@ export class GameEngine {
} }
} }
damageEnemy(e: Enemy, dmg: number, source?: Tower): void { damageEnemy(e: Enemy, dmg: number, source?: Tower, silent = false): void {
if (e.dead || e.escaped) return if (e.dead || e.escaped) return
// ZERBRECHLICH (Eisturm L7+): frozen enemies take +50% damage from ALL sources
if (e.slowFactor === 0 && this.time < e.slowUntil) dmg *= 1.5
const applied = Math.min(e.hp, dmg) const applied = Math.min(e.hp, dmg)
e.hp -= dmg e.hp -= dmg
e.flash = 1 if (!silent) e.flash = 1
if (source) source.damageDealt += applied if (source) source.damageDealt += applied
if (e.hp <= 0) { if (e.hp <= 0) {
e.dead = true e.dead = true
@ -997,6 +1124,12 @@ export class GameEngine {
if (p.kind === 'arrow') { if (p.kind === 'arrow') {
if (p.target && !p.target.dead && !p.target.escaped) { if (p.target && !p.target.dead && !p.target.escaped) {
this.damageEnemy(p.target, p.damage, owner) this.damageEnemy(p.target, p.damage, owner)
// GIFT (Bogenturm L7+)
const srcDef = owner ? TOWERS[owner.kind] : null
const dps = srcDef?.dotDps?.[owner!.level - 1] ?? 0
if (owner && dps > 0) {
this.applyDot(p.target, dps, srcDef!.dotDuration![owner.level - 1], srcDef!.dotColor ?? '#7ddc4f', owner.id)
}
} }
return return
} }
@ -1018,10 +1151,18 @@ export class GameEngine {
grav: 200, grav: 200,
}) })
} }
const burnDps = owner ? TOWERS[owner.kind].dotDps?.[owner.level - 1] ?? 0 : 0
// Flak (Kanone L7+): Explosion trifft ab jetzt auch Flieger
const canFlak = owner ? !!TOWERS[owner.kind].flak?.[owner.level - 1] : false
for (const e of this.enemies) { for (const e of this.enemies) {
if (e.dead || e.escaped || e.flying) continue if (e.dead || e.escaped) continue
if (e.flying && !canFlak) continue
if (dist(p.x, p.y, e.x, e.y) <= p.splash + e.r) { if (dist(p.x, p.y, e.x, e.y) <= p.splash + e.r) {
this.damageEnemy(e, p.damage, owner) this.damageEnemy(e, p.damage, owner)
// BRAND (Kanone L4+)
if (owner && burnDps > 0 && !e.dead) {
this.applyDot(e, burnDps, TOWERS[owner.kind].dotDuration![owner.level - 1], TOWERS[owner.kind].dotColor ?? '#ff9c40', owner.id)
}
} }
} }
this.shake = Math.max(this.shake, 0.15) this.shake = Math.max(this.shake, 0.15)
@ -1113,7 +1254,7 @@ export class GameEngine {
} }
case 'upgrade': { case 'upgrade': {
const t = this.towers.find((x) => x.id === a.towerId) const t = this.towers.find((x) => x.id === a.towerId)
if (!t || t.level >= 3) return false if (!t || t.level >= 9) return false
if (t.owner !== null && t.owner !== from) { if (t.owner !== null && t.owner !== from) {
if (local) this.sfx('error') if (local) this.sfx('error')
return false return false
@ -1262,12 +1403,23 @@ export class GameEngine {
} else { } else {
const def = TOWERS[t.kind] const def = TOWERS[t.kind]
const stats = def.levels[t.level - 1] const stats = def.levels[t.level - 1]
const maxed = t.level >= 3 const maxed = t.level >= 9
let special = '' const L = t.level
if (def.splash) special = `Fläche: ${def.splash[t.level - 1]}px` const parts: string[] = []
if (def.slowFactor) special = `Slow: ${Math.round((1 - def.slowFactor[t.level - 1]) * 100)}% für ${def.slowDuration![t.level - 1]}s` if (def.multi && def.multi[L - 1] > 1) parts.push(`${def.multi[L - 1]}× Pfeile`)
if (def.chain) special = `Kette: ${def.chain[t.level - 1]} Ziele` if (def.dotDps && def.dotDps[L - 1] > 0) parts.push(t.kind === 'arrow' ? `Gift ${def.dotDps[L - 1]}/s` : `Brand ${def.dotDps[L - 1]}/s`)
if (t.kind === 'laser') special = 'Soforttreffer' if (def.splash) parts.push(`Fläche ${def.splash[L - 1]}px`)
if (def.flak?.[L - 1]) parts.push('Flak: trifft Flieger')
if (def.slowFactor) parts.push(`Slow ${Math.round((1 - def.slowFactor[L - 1]) * 100)}%`)
if (def.freezeDuration && def.freezeDuration[L - 1] > 0) parts.push(`Einfrieren ${def.freezeDuration[L - 1]}s (jeder 3. Puls)`)
if (t.kind === 'frost' && L >= 7) parts.push('Zerbrechlich: +50% Schaden an Eingefrorenen')
if (def.chain) parts.push(`Kette ${def.chain[L - 1]}`)
if (def.stunDuration && def.stunDuration[L - 1] > 0) parts.push(`Betäubung ${def.stunDuration[L - 1]}s`)
if (def.stormEvery && def.stormEvery[L - 1] > 0) parts.push(`Gewitter alle ${def.stormEvery[L - 1]}. Schüsse`)
if (def.prism && def.prism[L - 1] > 0) parts.push(`Prisma ×${def.prism[L - 1]}`)
if (def.pierce?.[L - 1]) parts.push('Durchschlagender Strahl')
if (t.kind === 'laser' && L < 4) parts.push('Soforttreffer')
const special = parts.join(' · ')
const info: SelectedTowerInfo = { const info: SelectedTowerInfo = {
id: t.id, id: t.id,
kind: t.kind, kind: t.kind,
@ -1279,13 +1431,18 @@ export class GameEngine {
damage: stats.damage, damage: stats.damage,
range: stats.range, range: stats.range,
rate: stats.rate, rate: stats.rate,
dps: Math.round(stats.damage * stats.rate * (def.chain ? def.chain[t.level - 1] * 0.8 : 1)), dps: Math.round(
stats.damage *
stats.rate *
(def.multi ? def.multi[t.level - 1] : 1) *
(def.chain ? def.chain[t.level - 1] * 0.8 : 1),
),
upgradeCost: maxed ? null : def.upgradeCost[t.level - 1], upgradeCost: maxed ? null : def.upgradeCost[t.level - 1],
sellValue: Math.round(t.invested * SELL_RATIO), sellValue: Math.round(t.invested * SELL_RATIO),
targeting: t.targeting, targeting: t.targeting,
kills: t.kills, kills: t.kills,
damageDealt: Math.round(t.damageDealt), damageDealt: Math.round(t.damageDealt),
hitsFlying: def.hitsFlying, hitsFlying: def.hitsFlying || !!def.flak?.[t.level - 1],
special, special,
} }
store.selected = info store.selected = info

View file

@ -1,4 +1,4 @@
import { ENEMIES, H, TILE, TOWERS, W, playerColor } from './config' import { ENEMIES, H, TILE, TOWERS, W, playerColor, towerTier, TIER_COLORS } from './config'
import type { GameEngine } from './engine' import type { GameEngine } from './engine'
import type { Enemy, Tower } from './types' import type { Enemy, Tower } from './types'
import { mulberry32 } from './utils' import { mulberry32 } from './utils'
@ -415,13 +415,26 @@ export class Renderer {
g.stroke() g.stroke()
} }
// level pips // level pips: 3 slots, color shows the tier (yellow/orange/blue)
for (let i = 0; i < t.level; i++) { const tier = towerTier(t.level)
g.fillStyle = '#ffd23e' const filled = ((t.level - 1) % 3) + 1
for (let i = 0; i < 3; i++) {
g.fillStyle = i < filled ? TIER_COLORS[tier] : '#3a4450'
g.beginPath() g.beginPath()
g.arc(-8 + i * 8, 13, 2.6, 0, Math.PI * 2) g.arc(-8 + i * 8, 13, 2.6, 0, Math.PI * 2)
g.fill() g.fill()
} }
// evolution aura for orange/blue tiers
if (tier > 0) {
const glow = 0.16 + Math.sin(eng.time * 3 + t.id) * 0.06
g.strokeStyle = TIER_COLORS[tier]
g.globalAlpha = glow + 0.25
g.lineWidth = 2
g.beginPath()
g.arc(0, 2, 24 + Math.sin(eng.time * 3 + t.id) * 1.5, 0, Math.PI * 2)
g.stroke()
g.globalAlpha = 1
}
const recoil = t.recoil * 4 const recoil = t.recoil * 4
const time = eng.time const time = eng.time
@ -704,15 +717,40 @@ export class Renderer {
// slow indicator // slow indicator
if (slowed) { if (slowed) {
g.strokeStyle = 'rgba(140,220,255,0.9)' const frozen = e.slowFactor === 0
g.lineWidth = 2 g.strokeStyle = frozen ? 'rgba(220,245,255,1)' : 'rgba(140,220,255,0.9)'
g.lineWidth = frozen ? 3.5 : 2
g.beginPath() g.beginPath()
g.arc(0, 0, e.r + 3.5, 0, Math.PI * 2) g.arc(0, 0, e.r + 3.5, 0, Math.PI * 2)
g.stroke() g.stroke()
g.fillStyle = 'rgba(160,230,255,0.25)' g.fillStyle = frozen ? 'rgba(200,240,255,0.45)' : 'rgba(160,230,255,0.25)'
g.beginPath() g.beginPath()
g.arc(0, 0, e.r + 1, 0, Math.PI * 2) g.arc(0, 0, e.r + 1, 0, Math.PI * 2)
g.fill() g.fill()
if (frozen) {
// ice spikes
g.strokeStyle = 'rgba(230,250,255,0.95)'
g.lineWidth = 2
for (let i = 0; i < 3; i++) {
const a = (i / 3) * Math.PI * 2 + 0.5
g.beginPath()
g.moveTo(Math.cos(a) * (e.r - 2), Math.sin(a) * (e.r - 2))
g.lineTo(Math.cos(a) * (e.r + 7), Math.sin(a) * (e.r + 7))
g.stroke()
}
}
}
// poison / burn indicator
if (eng.time < e.dotUntil && e.dotDps > 0) {
g.fillStyle = e.dotColor
g.beginPath()
g.arc(0, -e.r - 4, 2.8, 0, Math.PI * 2)
g.fill()
g.fillStyle = 'rgba(255,255,255,0.85)'
g.beginPath()
g.arc(-1, -e.r - 5, 1, 0, Math.PI * 2)
g.fill()
} }
// hit flash // hit flash
@ -953,6 +991,7 @@ export class Renderer {
invested: 0, invested: 0,
recoil: 0, recoil: 0,
pulse: 0, pulse: 0,
counter: 0,
targetId: null, targetId: null,
owner: null, owner: null,
}, },

View file

@ -47,19 +47,38 @@ export interface TowerDef {
icon: string icon: string
desc: string desc: string
cost: number cost: number
/** upgrade cost to level 2 and 3 */ /** upgrade cost to level 2..9 (8 entries) */
upgradeCost: [number, number] upgradeCost: number[]
levels: [TowerLevelStats, TowerLevelStats, TowerLevelStats] /** 9 levels: 1-3 yellow (base), 4-6 orange (evolution), 7-9 blue (final evolution) */
levels: TowerLevelStats[]
hitsFlying: boolean hitsFlying: boolean
projectileSpeed?: number projectileSpeed?: number
/** splash radius per level (cannon) */ /** splash radius per level (cannon) */
splash?: [number, number, number] splash?: number[]
/** speed multiplier applied while slowed, per level (frost) */ /** speed multiplier applied while slowed, per level (frost) */
slowFactor?: [number, number, number] slowFactor?: number[]
slowDuration?: [number, number, number] slowDuration?: number[]
/** chain lightning targets per level (tesla) */ /** chain lightning targets per level (tesla) */
chain?: [number, number, number] chain?: number[]
chainRange?: number chainRange?: number[]
/** arrow: arrows per shot */
multi?: number[]
/** damage over time per level (arrow: poison / cannon: burn) */
dotDps?: number[]
dotDuration?: number[]
dotColor?: string
/** cannon: can hit flyers from level 7 on */
flak?: boolean[]
/** frost: every 3rd pulse freezes enemies */
freezeDuration?: number[]
/** tesla: stun per hit */
stunDuration?: number[]
/** tesla: every Nth shot hits ALL enemies in range */
stormEvery?: number[]
/** laser: beam refracts onto N extra targets */
prism?: number[]
/** laser: piercing beam hits everything in a line */
pierce?: boolean[]
color: string color: string
accent: string accent: string
hotkey: string hotkey: string
@ -82,6 +101,11 @@ export interface Enemy {
traveled: number traveled: number
slowUntil: number slowUntil: number
slowFactor: number slowFactor: number
/** damage over time (poison/burn) */
dotDps: number
dotUntil: number
dotSourceId: number
dotColor: string
dead: boolean dead: boolean
escaped: boolean escaped: boolean
flash: number flash: number
@ -97,7 +121,10 @@ export interface Tower {
ty: number ty: number
x: number x: number
y: number y: number
level: 1 | 2 | 3 /** 1..9 */
level: number
/** generic counter for evolutions (frost pulse count, tesla shots) */
counter: number
cooldown: number cooldown: number
angle: number angle: number
kills: number kills: number