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
27
README.md
27
README.md
|
|
@ -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** | 1–3 | 🟡 Gelb | Höherer Grundschaden, Reichweite & Feuerrate |
|
||||||
| 🧨 Kanone | 110 🪙 | 100 / 180 🪙 | Flächenschaden, trifft **keine** Flieger |
|
| **Evolution I** | 4–6 | 🟠 Orange | Neue Spezialfähigkeit schaltet sich frei |
|
||||||
| ❄️ Eisturm | 80 🪙 | 70 / 130 🪙 | Verlangsamt alle Gegner im Umkreis um bis zu 65 % |
|
| **Evolution II** | 7–9 | 🔵 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 (L1–3) | 🟠 Evolution I (L4–6) | 🔵 Evolution II (L7–9) |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 🏹 **Bogenturm** | Schneller Einzelschuss | **Mehrfachschuss:** Feuert 2–3 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 2–3 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
133
scripts/test-evolution.mts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue