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:
parent
ef75203db7
commit
4f8400c480
8 changed files with 518 additions and 98 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue