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

@ -23,8 +23,13 @@ function canAct(): boolean {
<div class="title">
<div class="name">{{ store.selected.name }}</div>
<div class="level">
<span v-for="i in 3" :key="i" class="star" :class="{ on: i <= store.selected.level }"></span>
<span class="lvl-text">Level {{ store.selected.level }}</span>
<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>
<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()"
@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>
</button>
<button class="btn sell" :disabled="!canAct()" @click="uiSellSelected()">
@ -109,9 +114,9 @@ function canAct(): boolean {
color: #3a4450;
font-size: 12px;
}
.star.on {
color: #ffd23e;
}
.star.on.t0 { color: #ffd23e; }
.star.on.t1 { color: #ff9c40; }
.star.on.t2 { color: #4da3ff; }
.lvl-text {
margin-left: 5px;
font-size: 11px;

View file

@ -53,14 +53,24 @@ export const TOWERS: Record<TowerKind, TowerDef> = {
icon: '🏹',
desc: 'Günstiger Standardturm mit schnellen Einzelschüssen.',
cost: 50,
upgradeCost: [60, 110],
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',
@ -71,15 +81,25 @@ export const TOWERS: Record<TowerKind, TowerDef> = {
icon: '🧨',
desc: 'Flächenschaden durch Explosion trifft aber keine Flieger.',
cost: 110,
upgradeCost: [100, 180],
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],
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',
@ -90,15 +110,22 @@ export const TOWERS: Record<TowerKind, TowerDef> = {
icon: '❄️',
desc: 'Frostpuls verlangsamt ALLE Gegner in Reichweite (auch Flieger).',
cost: 80,
upgradeCost: [70, 130],
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],
slowDuration: [1.5, 1.9, 2.3],
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',
@ -109,15 +136,23 @@ export const TOWERS: Record<TowerKind, TowerDef> = {
icon: '⚡',
desc: 'Kettenblitz springt auf mehrere Gegner über.',
cost: 130,
upgradeCost: [120, 200],
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],
chainRange: 100,
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',
@ -128,19 +163,38 @@ export const TOWERS: Record<TowerKind, TowerDef> = {
icon: '💫',
desc: 'Soforttreffer mit hoher Reichweite und hohem Schaden.',
cost: 160,
upgradeCost: [150, 260],
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',

View file

@ -539,6 +539,10 @@ export class GameEngine {
traveled: 0,
slowUntil: 0,
slowFactor: 1,
dotDps: 0,
dotUntil: 0,
dotSourceId: -1,
dotColor: '#7ddc4f',
dead: false,
escaped: false,
flash: 0,
@ -551,6 +555,12 @@ export class GameEngine {
private updateEnemy(e: Enemy, dt: number): void {
if (e.dead || e.escaped) return
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 speed = e.speedBase * (slowed ? e.slowFactor : 1)
const path = e.flying ? this.flyPathPx : this.pathPx
@ -626,6 +636,7 @@ export class GameEngine {
invested: def.cost,
recoil: 0,
pulse: 0,
counter: 0,
targetId: null,
owner,
})
@ -661,7 +672,7 @@ export class GameEngine {
upgradeTower(t: Tower): void {
const def = TOWERS[t.kind]
if (t.level >= 3) return
if (t.level >= 9) return
const cost = def.upgradeCost[t.level - 1]
if (this.money < cost) {
this.sfx('error')
@ -669,7 +680,7 @@ export class GameEngine {
}
this.money -= cost
t.invested += cost
t.level = (t.level + 1) as Tower['level']
t.level = Math.min(9, t.level + 1)
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: '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]
}
private acquireTarget(t: Tower): Enemy | undefined {
private canHit(t: Tower, e: Enemy): boolean {
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)
let best: Enemy | undefined
let bestScore = -Infinity
const scored: { e: Enemy; s: number }[] = []
for (const e of this.enemies) {
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)
if (d > stats.range + e.r) continue
let s: number
@ -731,12 +745,29 @@ export class GameEngine {
s = -d
break
}
if (s > bestScore) {
bestScore = s
best = e
}
scored.push({ e, s })
}
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 {
@ -759,16 +790,33 @@ export class GameEngine {
if (any) {
t.cooldown = 1 / stats.rate
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')
for (const e of this.enemies) {
if (e.dead || e.escaped) continue
if (dist(t.x, t.y, e.x, e.y) <= stats.range + e.r) {
const sf = def.slowFactor![t.level - 1]
const sd = def.slowDuration![t.level - 1]
if (this.time >= e.slowUntil || sf <= e.slowFactor) {
e.slowFactor = sf
e.slowUntil = this.time + sd
if (doFreeze) {
this.applyFreeze(e, freezeDur)
} else {
const sf = def.slowFactor![t.level - 1]
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)
}
@ -781,7 +829,7 @@ export class GameEngine {
// validate / reacquire target
let target: Enemy | 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
}
if (!target) {
@ -808,21 +856,26 @@ export class GameEngine {
switch (t.kind) {
case 'arrow': {
this.projectiles.push({
kind: 'arrow',
x: bx,
y: by,
angle: t.angle,
speed: def.projectileSpeed!,
damage: stats.damage,
splash: 0,
target,
tx: target.x,
ty: target.y,
life: 3,
trail: 0,
ownerId: t.id,
})
const multi = def.multi?.[t.level - 1] ?? 1
const targets = multi > 1 ? this.acquireTargets(t, multi) : [target]
if (targets.length === 0) targets.push(target)
for (const tg of targets) {
this.projectiles.push({
kind: 'arrow',
x: bx,
y: by,
angle: angleTo(bx, by, tg.x, tg.y),
speed: def.projectileSpeed!,
damage: stats.damage,
splash: 0,
target: tg,
tx: tg.x,
ty: tg.y,
life: 3,
trail: 0,
ownerId: t.id,
})
}
this.sfx('shootArrow')
break
}
@ -861,14 +914,40 @@ export class GameEngine {
break
}
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 chainRange = def.chainRange![t.level - 1]
const hit: Enemy[] = [target]
const dmg = stats.damage
this.damageEnemy(target, dmg, t)
if (stunDur > 0) this.applyFreeze(target, stunDur)
let prev = target
while (hit.length < chainCount) {
let next: Enemy | undefined
let nd = def.chainRange!
let nd = chainRange
for (const e of this.enemies) {
if (e.dead || e.escaped || hit.includes(e)) continue
const d = dist(prev.x, prev.y, e.x, e.y)
@ -880,6 +959,7 @@ export class GameEngine {
if (!next) break
hit.push(next)
this.damageEnemy(next, dmg * Math.pow(0.75, hit.length - 1), t)
if (stunDur > 0) this.applyFreeze(next, stunDur)
prev = next
}
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
}
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.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++) {
@ -905,6 +1010,26 @@ export class GameEngine {
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')
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
// 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)
e.hp -= dmg
e.flash = 1
if (!silent) e.flash = 1
if (source) source.damageDealt += applied
if (e.hp <= 0) {
e.dead = true
@ -997,6 +1124,12 @@ export class GameEngine {
if (p.kind === 'arrow') {
if (p.target && !p.target.dead && !p.target.escaped) {
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
}
@ -1018,10 +1151,18 @@ export class GameEngine {
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) {
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) {
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)
@ -1113,7 +1254,7 @@ export class GameEngine {
}
case 'upgrade': {
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 (local) this.sfx('error')
return false
@ -1262,12 +1403,23 @@ export class GameEngine {
} else {
const def = TOWERS[t.kind]
const stats = def.levels[t.level - 1]
const maxed = t.level >= 3
let special = ''
if (def.splash) special = `Fläche: ${def.splash[t.level - 1]}px`
if (def.slowFactor) special = `Slow: ${Math.round((1 - def.slowFactor[t.level - 1]) * 100)}% für ${def.slowDuration![t.level - 1]}s`
if (def.chain) special = `Kette: ${def.chain[t.level - 1]} Ziele`
if (t.kind === 'laser') special = 'Soforttreffer'
const maxed = t.level >= 9
const L = t.level
const parts: string[] = []
if (def.multi && def.multi[L - 1] > 1) parts.push(`${def.multi[L - 1]}× Pfeile`)
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 (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 = {
id: t.id,
kind: t.kind,
@ -1279,13 +1431,18 @@ export class GameEngine {
damage: stats.damage,
range: stats.range,
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],
sellValue: Math.round(t.invested * SELL_RATIO),
targeting: t.targeting,
kills: t.kills,
damageDealt: Math.round(t.damageDealt),
hitsFlying: def.hitsFlying,
hitsFlying: def.hitsFlying || !!def.flak?.[t.level - 1],
special,
}
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 { Enemy, Tower } from './types'
import { mulberry32 } from './utils'
@ -415,13 +415,26 @@ export class Renderer {
g.stroke()
}
// level pips
for (let i = 0; i < t.level; i++) {
g.fillStyle = '#ffd23e'
// level pips: 3 slots, color shows the tier (yellow/orange/blue)
const tier = towerTier(t.level)
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.arc(-8 + i * 8, 13, 2.6, 0, Math.PI * 2)
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 time = eng.time
@ -704,15 +717,40 @@ export class Renderer {
// slow indicator
if (slowed) {
g.strokeStyle = 'rgba(140,220,255,0.9)'
g.lineWidth = 2
const frozen = e.slowFactor === 0
g.strokeStyle = frozen ? 'rgba(220,245,255,1)' : 'rgba(140,220,255,0.9)'
g.lineWidth = frozen ? 3.5 : 2
g.beginPath()
g.arc(0, 0, e.r + 3.5, 0, Math.PI * 2)
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.arc(0, 0, e.r + 1, 0, Math.PI * 2)
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
@ -953,6 +991,7 @@ export class Renderer {
invested: 0,
recoil: 0,
pulse: 0,
counter: 0,
targetId: null,
owner: null,
},

View file

@ -47,19 +47,38 @@ export interface TowerDef {
icon: string
desc: string
cost: number
/** upgrade cost to level 2 and 3 */
upgradeCost: [number, number]
levels: [TowerLevelStats, TowerLevelStats, TowerLevelStats]
/** upgrade cost to level 2..9 (8 entries) */
upgradeCost: number[]
/** 9 levels: 1-3 yellow (base), 4-6 orange (evolution), 7-9 blue (final evolution) */
levels: TowerLevelStats[]
hitsFlying: boolean
projectileSpeed?: number
/** splash radius per level (cannon) */
splash?: [number, number, number]
splash?: number[]
/** speed multiplier applied while slowed, per level (frost) */
slowFactor?: [number, number, number]
slowDuration?: [number, number, number]
slowFactor?: number[]
slowDuration?: number[]
/** chain lightning targets per level (tesla) */
chain?: [number, number, number]
chainRange?: number
chain?: 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
accent: string
hotkey: string
@ -82,6 +101,11 @@ export interface Enemy {
traveled: number
slowUntil: number
slowFactor: number
/** damage over time (poison/burn) */
dotDps: number
dotUntil: number
dotSourceId: number
dotColor: string
dead: boolean
escaped: boolean
flash: number
@ -97,7 +121,10 @@ export interface Tower {
ty: number
x: number
y: number
level: 1 | 2 | 3
/** 1..9 */
level: number
/** generic counter for evolutions (frost pulse count, tesla shots) */
counter: number
cooldown: number
angle: number
kills: number