diff --git a/src/App.css b/src/App.css index 6af49a5..ee51856 100644 --- a/src/App.css +++ b/src/App.css @@ -41,6 +41,8 @@ display: flex; justify-content: space-between; gap: 12px; + padding-right: 44px; + box-sizing: border-box; } .hud-hint { @@ -113,6 +115,57 @@ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); } +.hp-bar { + position: relative; + width: 100%; + height: 18px; + margin-top: 4px; + border-radius: 9px; + background: rgba(255, 255, 255, 0.12); + overflow: hidden; +} + +.hp-bar-fill { + position: absolute; + inset: 0; + width: 0%; + background: linear-gradient(90deg, #ff5a7a, #7bffb0); + transition: width 0.15s ease-out; +} + +.hp-bar-text { + position: relative; + display: block; + text-align: center; + font-size: 11px; + line-height: 18px; + color: #fff; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); +} + +.music-btn { + position: absolute; + top: 10px; + right: 16px; + width: 32px; + height: 32px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.18); + background: rgba(11, 13, 32, 0.6); + color: #eef1ff; + font-size: 15px; + line-height: 1; + cursor: pointer; + pointer-events: auto; + display: flex; + align-items: center; + justify-content: center; +} + +.music-btn:hover { + border-color: #5b8cff; +} + /* ------------------------------------------------------------ boss banner */ .boss-banner { position: absolute; @@ -168,11 +221,44 @@ color: #ffd7de; } +/* ----------------------------------------------------------- chase banner */ +.chase-banner { + position: absolute; + top: 76px; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 10px 20px; + border-radius: 10px; + background: rgba(20, 8, 20, 0.6); + border: 1px solid rgba(180, 90, 255, 0.5); + pointer-events: none; +} + +.chase-title { + font-weight: 700; + color: #d7b3ff; + letter-spacing: 0.04em; +} + +.chase-sub { + font-size: 13px; + color: #ffdede; +} + +.chase-hint { + font-size: 12px; + color: #c8ceec; +} + /* -------------------------------------------------------------- shop panel */ .shop-panel { flex: 0 0 auto; display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); + grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 8px; padding: 10px; background: #0b0d20; @@ -258,7 +344,7 @@ @media (max-width: 860px) { .shop-panel { - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); } .hud-zone { @@ -266,6 +352,12 @@ } } +@media (max-width: 560px) { + .shop-panel { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + /* --------------------------------------------------------- supernova cutscene */ .supernova-overlay { position: absolute; diff --git a/src/App.tsx b/src/App.tsx index 819de61..4f21327 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import { useRef } from 'react'; import { BossBanner } from './components/BossBanner'; +import { ChaseBanner } from './components/ChaseBanner'; import { Hud } from './components/Hud'; import { LoreModal } from './components/LoreModal'; import { ShopPanel } from './components/ShopPanel'; @@ -15,8 +16,11 @@ function App() {
- {snapshot && !snapshot.cutsceneActive && } + {snapshot && !snapshot.cutsceneActive && ( + engine.current?.toggleMusic()} /> + )} {snapshot?.bossActive && !snapshot.cutsceneActive && } + {snapshot?.chaseActive && !snapshot.cutsceneActive && } {snapshot?.cutsceneActive && } {snapshot?.loreActive && snapshot.loreEntry && ( engine.current?.dismissLore()} /> diff --git a/src/components/ChaseBanner.tsx b/src/components/ChaseBanner.tsx new file mode 100644 index 0000000..c138106 --- /dev/null +++ b/src/components/ChaseBanner.tsx @@ -0,0 +1,25 @@ +import type { GameSnapshot } from '../game/types'; + +export function ChaseBanner({ snapshot }: { snapshot: GameSnapshot }) { + if (!snapshot.chaseActive) return null; + + if (snapshot.chaseKind === 'cruiser') { + return ( +
+
Погоня: Пиратский Крейсер
+
+ Топливных ячеек: {snapshot.chaseFuelCells}/{snapshot.chaseFuelRequired} +
+
Уворачивайтесь от лазеров и астероидов
+
+ ); + } + + return ( +
+
Погоня: Чёрный Рой
+
Осталось: {Math.ceil(snapshot.chaseTimeRemaining)} с
+
Уворачивайтесь от плазмы
+
+ ); +} diff --git a/src/components/Hud.tsx b/src/components/Hud.tsx index fcc94a8..b8f5dcb 100644 --- a/src/components/Hud.tsx +++ b/src/components/Hud.tsx @@ -1,8 +1,9 @@ import { formatNumber } from '../game/format'; import type { GameSnapshot } from '../game/types'; -export function Hud({ snapshot }: { snapshot: GameSnapshot }) { +export function Hud({ snapshot, onToggleMusic }: { snapshot: GameSnapshot; onToggleMusic: () => void }) { const progressPct = Math.min(100, (snapshot.crystalsInZone / snapshot.crystalsRequired) * 100); + const hpPct = Math.min(100, (snapshot.knightHp / snapshot.knightMaxHp) * 100); return (
@@ -10,19 +11,32 @@ export function Hud({ snapshot }: { snapshot: GameSnapshot }) { Очки {formatNumber(snapshot.score)}
+
+ Здоровье +
+
+ + {Math.ceil(snapshot.knightHp)}/{Math.ceil(snapshot.knightMaxHp)} + +
+
Зона {snapshot.zone} - {!snapshot.bossActive ? ( + {snapshot.bossActive ? ( +
+ Битва с боссом +
+ ) : snapshot.chaseActive ? ( +
+ Погоня! +
+ ) : (
{snapshot.crystalsInZone}/{snapshot.crystalsRequired} кристаллов
- ) : ( -
- Битва с боссом -
)}
@@ -31,6 +45,9 @@ export function Hud({ snapshot }: { snapshot: GameSnapshot }) {
Тап по экрану — полёт · уворачивайтесь от астероидов
+
); } diff --git a/src/components/ShopPanel.tsx b/src/components/ShopPanel.tsx index 91ec2ee..5c29a74 100644 --- a/src/components/ShopPanel.tsx +++ b/src/components/ShopPanel.tsx @@ -2,7 +2,7 @@ import { UPGRADES } from '../game/constants'; import { formatNumber } from '../game/format'; import type { GameSnapshot, UpgradeKey } from '../game/types'; -const KEYS: UpgradeKey[] = ['crystal', 'speed', 'damage', 'droid']; +const KEYS: UpgradeKey[] = ['crystal', 'speed', 'damage', 'droid', 'vitality']; export function ShopPanel({ snapshot, diff --git a/src/game/GameEngine.ts b/src/game/GameEngine.ts index 5b44708..c382840 100644 --- a/src/game/GameEngine.ts +++ b/src/game/GameEngine.ts @@ -1,12 +1,19 @@ import { WORLD, UPGRADES, DROID_TIERS, LORE_ENTRIES, type DroidTierConfig } from './constants'; import { formatNumber } from './format'; +import { MusicEngine } from './music'; import { makeJitter, mulberry32 } from './random'; import type { AsteroidEntity, + BossBomb, + BossProjectile, + BossType, + ChaseKind, + ChaseProjectile, CrystalEntity, DroidEntity, EnemyEntity, FloatingText, + FuelCellEntity, GameSnapshot, GoldenAsteroidEntity, LoreEntry, @@ -25,6 +32,8 @@ interface SaveData { seenLore?: number[]; } +const BOSS_PATTERN: BossType[] = ['melee', 'missile', 'bomb', 'laser']; + let idCounter = 1; const nextId = () => idCounter++; @@ -41,6 +50,19 @@ interface KnightState { hitFlash: number; locked: boolean; attackTimer: number; + hp: number; + invulnTimer: number; +} + +interface ChaseState { + active: boolean; + kind: ChaseKind | null; + fuelCells: number; + timer: number; + attackTimer: number; + engulfed: boolean; + engulfTimer: number; + escapeMeter: number; } export class GameEngine { @@ -61,7 +83,7 @@ export class GameEngine { zone = 1; crystalsInZone = 0; darkMatter = 0; - levels: Record = { crystal: 1, speed: 1, damage: 1, droid: 1 }; + levels: Record = { crystal: 1, speed: 1, damage: 1, droid: 1, vitality: 1 }; private crystals: CrystalEntity[] = []; private pirate: EnemyEntity | null = null; @@ -71,6 +93,7 @@ export class GameEngine { private goldenAsteroid: GoldenAsteroidEntity | null = null; private particles: Particle[] = []; private floatingTexts: FloatingText[] = []; + private music = new MusicEngine(); private crystalSpawnTimer = 0; private pirateSpawnTimer = 0; @@ -84,6 +107,27 @@ export class GameEngine { private lore: { active: boolean; entry: LoreEntry | null } = { active: false, entry: null }; private seenLore = new Set(); + private bossNumber = 1; + private bossType: BossType = 'melee'; + private bossAttackTimer = 0; + private bossProjectiles: BossProjectile[] = []; + private bossBomb: BossBomb | null = null; + private bossLaserAngle = 0; + + private chase: ChaseState = { + active: false, + kind: null, + fuelCells: 0, + timer: 0, + attackTimer: 0, + engulfed: false, + engulfTimer: WORLD.SWARM_ENGULF_INTERVAL, + escapeMeter: 0, + }; + private chaseProjectiles: ChaseProjectile[] = []; + private fuelCellEntities: FuelCellEntity[] = []; + private fuelCellSpawnTimer = 0; + private knight: KnightState = { x: 0, y: 0, @@ -93,6 +137,8 @@ export class GameEngine { hitFlash: 0, locked: false, attackTimer: 0, + hp: WORLD.BASE_KNIGHT_MAX_HP, + invulnTimer: 0, }; private stars: { x: number; y: number; r: number; a: number }[] = []; @@ -111,9 +157,10 @@ export class GameEngine { this.resize(); this.knight.x = this.width / 2; this.knight.y = this.height / 2; + this.knight.hp = this.knightMaxHp(); this.initStars(); this.rebuildDroids(); - if (this.zone % 10 === 0) this.startBoss(); + this.resumeEncounterForCurrentZone(); } // ---------------------------------------------------------------- lifecycle @@ -136,6 +183,7 @@ export class GameEngine { this.canvas.removeEventListener('pointerdown', this.onPointerDown); window.removeEventListener('resize', this.onResize); this.resizeObserver?.disconnect(); + this.music.stop(); this.save(); } @@ -177,7 +225,14 @@ export class GameEngine { // ------------------------------------------------------------------ input private handlePointerDown(e: PointerEvent) { + this.music.start(); if (this.cutscene.active || this.lore.active) return; + + if (this.chase.active && this.chase.kind === 'swarm' && this.chase.engulfed) { + this.chase.escapeMeter = Math.min(1, this.chase.escapeMeter + WORLD.SWARM_ESCAPE_FILL_PER_TAP); + return; + } + const rect = this.canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; @@ -215,6 +270,10 @@ export class GameEngine { return WORLD.BASE_KNIGHT_SPEED * (1 + (this.levels.speed - 1) * UPGRADES.speed.bonus); } + private knightMaxHp(): number { + return WORLD.BASE_KNIGHT_MAX_HP * (1 + (this.levels.vitality - 1) * UPGRADES.vitality.bonus); + } + private crystalValue(): number { return ( WORLD.BASE_CRYSTAL_VALUE * @@ -244,6 +303,7 @@ export class GameEngine { this.spawnCrystal(this.knight.x + Math.cos(ang) * 46, this.knight.y + Math.sin(ang) * 46); } if (key === 'droid') this.rebuildDroids(); + if (key === 'vitality') this.knight.hp = this.knightMaxHp(); this.save(); this.notify(); return true; @@ -265,15 +325,37 @@ export class GameEngine { this.notify(); } + toggleMusic(): boolean { + const muted = this.music.toggleMute(); + this.notify(); + return muted; + } + private applyPrestigeReset() { this.darkMatter += WORLD.SUPERNOVA_DARK_MATTER_GAIN; this.score = 0; this.zone = 1; this.crystalsInZone = 0; - this.levels = { crystal: 1, speed: 1, damage: 1, droid: 1 }; + this.levels = { crystal: 1, speed: 1, damage: 1, droid: 1, vitality: 1 }; this.crystals = []; this.pirate = null; this.boss = null; + this.bossProjectiles = []; + this.bossBomb = null; + this.bossAttackTimer = 0; + this.chase = { + active: false, + kind: null, + fuelCells: 0, + timer: 0, + attackTimer: 0, + engulfed: false, + engulfTimer: WORLD.SWARM_ENGULF_INTERVAL, + escapeMeter: 0, + }; + this.chaseProjectiles = []; + this.fuelCellEntities = []; + this.fuelCellSpawnTimer = 0; this.asteroids = []; this.goldenAsteroid = null; this.particles = []; @@ -285,6 +367,8 @@ export class GameEngine { this.knight.locked = false; this.knight.x = this.width / 2; this.knight.y = this.height / 2; + this.knight.hp = this.knightMaxHp(); + this.knight.invulnTimer = 0; this.moveTarget = null; this.rebuildDroids(); this.save(); @@ -361,9 +445,66 @@ export class GameEngine { }; } + private resumeEncounterForCurrentZone() { + if (this.zone === 20) this.startChase('cruiser'); + else if (this.zone === 40) this.startChase('swarm'); + else if (this.zone % 20 === 10) this.startBoss(); + } + + private startChase(kind: ChaseKind) { + this.chase = { + active: true, + kind, + fuelCells: 0, + timer: 0, + attackTimer: 0, + engulfed: false, + engulfTimer: WORLD.SWARM_ENGULF_INTERVAL, + escapeMeter: 0, + }; + this.chaseProjectiles = []; + this.fuelCellEntities = []; + this.fuelCellSpawnTimer = 0; + this.crystals = []; + this.pirate = null; + this.boss = null; + this.knight.locked = false; + this.moveTarget = null; + this.knight.hp = this.knightMaxHp(); + this.knight.invulnTimer = WORLD.HIT_INVULN_DURATION; + this.knight.x = this.width / 2; + this.knight.y = this.height * 0.72; + } + + private restartChase() { + if (!this.chase.active) return; + this.chase.timer = 0; + this.chase.fuelCells = 0; + this.chase.attackTimer = 0; + this.chase.engulfed = false; + this.chase.engulfTimer = WORLD.SWARM_ENGULF_INTERVAL; + this.chase.escapeMeter = 0; + this.chaseProjectiles = []; + this.fuelCellEntities = []; + this.fuelCellSpawnTimer = 0; + this.knight.hp = this.knightMaxHp(); + this.knight.invulnTimer = WORLD.HIT_INVULN_DURATION; + this.knight.x = this.width / 2; + this.knight.y = this.height * 0.72; + this.moveTarget = null; + this.spawnFloatingText(this.knight.x, this.knight.y - 40, 'Погоня начинается заново!', '#ff5a7a'); + } + private startBoss() { const id = nextId(); const hp = this.pirateHp() * WORLD.BOSS_HP_MULT; + this.bossNumber = Math.round((this.zone - 10) / 20) + 1; + this.bossType = BOSS_PATTERN[(this.bossNumber - 1) % BOSS_PATTERN.length]; + const radius = clamp( + WORLD.BOSS_BASE_RADIUS + (this.bossNumber - 1) * WORLD.BOSS_RADIUS_PER_TIER, + WORLD.BOSS_BASE_RADIUS, + WORLD.BOSS_MAX_RADIUS, + ); this.boss = { id, x: this.width / 2, @@ -371,13 +512,17 @@ export class GameEngine { hp, maxHp: hp, speed: WORLD.BASE_BOSS_SPEED, - radius: 48, + radius, isBoss: true, state: 'chase', wanderAngle: 0, jitter: makeJitter(id, 10, 0.2), hitFlash: 0, }; + this.bossAttackTimer = 0; + this.bossProjectiles = []; + this.bossBomb = null; + this.bossLaserAngle = Math.random() * Math.PI * 2; this.knight.locked = false; this.knight.attackTimer = 0; this.crystals = []; @@ -419,6 +564,51 @@ export class GameEngine { }; } + private spawnFuelCell() { + const margin = 40; + const id = nextId(); + this.fuelCellEntities.push({ + id, + x: margin + Math.random() * (this.width - margin * 2), + y: margin + Math.random() * (this.height - margin * 2), + radius: 13, + rotation: Math.random() * Math.PI * 2, + spin: (Math.random() > 0.5 ? 1 : -1) * 0.8, + jitter: makeJitter(id, 6, 0.14), + }); + } + + private fireChaseProjectile(kind: 'laser' | 'plasma', speed: number, radius: number) { + const originX = clamp(this.knight.x + (Math.random() - 0.5) * 320, 30, this.width - 30); + const originY = -20; + const dx = this.knight.x - originX; + const dy = this.knight.y - originY; + const d = Math.hypot(dx, dy) || 1; + this.chaseProjectiles.push({ + id: nextId(), + kind, + x: originX, + y: originY, + vx: (dx / d) * speed, + vy: (dy / d) * speed, + radius, + }); + } + + private updateChaseProjectiles(dt: number, damageRatio: number) { + this.chaseProjectiles = this.chaseProjectiles.filter((p) => { + p.x += p.vx * dt; + p.y += p.vy * dt; + if (p.x < -60 || p.x > this.width + 60 || p.y < -60 || p.y > this.height + 60) return false; + const d = Math.hypot(p.x - this.knight.x, p.y - this.knight.y); + if (d <= p.radius + WORLD.KNIGHT_HIT_RADIUS) { + this.damageKnight(Math.max(1, Math.round(this.knightMaxHp() * damageRatio))); + return false; + } + return true; + }); + } + private catchGoldenAsteroid(g: GoldenAsteroidEntity) { const reward = this.crystalValue() * WORLD.GOLDEN_ASTEROID_REWARD_MULT; this.score += reward; @@ -494,7 +684,8 @@ export class GameEngine { // frozen: the story archive modal pauses the world until dismissed } else { this.updateAsteroids(dt); - if (this.boss) this.updateBossMode(dt); + if (this.chase.active) this.updateChase(dt); + else if (this.boss) this.updateBossMode(dt); else this.updateNormalMode(dt); this.updateDroids(dt); this.checkDeaths(); @@ -506,6 +697,7 @@ export class GameEngine { if (this.pirate && this.pirate.hitFlash > 0) this.pirate.hitFlash = Math.max(0, this.pirate.hitFlash - dt * 3); if (this.boss && this.boss.hitFlash > 0) this.boss.hitFlash = Math.max(0, this.boss.hitFlash - dt * 3); if (this.knight.hitFlash > 0) this.knight.hitFlash = Math.max(0, this.knight.hitFlash - dt * 3); + if (this.knight.invulnTimer > 0) this.knight.invulnTimer = Math.max(0, this.knight.invulnTimer - dt); } this.snapshotTimer += dt; @@ -541,6 +733,30 @@ export class GameEngine { e.hitFlash = 1; } + private damageKnight(amount: number) { + if (this.knight.invulnTimer > 0) return; + this.knight.hp = Math.max(0, this.knight.hp - amount); + this.knight.invulnTimer = WORLD.HIT_INVULN_DURATION; + this.knight.hitFlash = 1; + this.spawnBurst(this.knight.x, this.knight.y, '255,90,122', 10, 100); + this.spawnFloatingText(this.knight.x, this.knight.y - 24, `-${amount}`, '#ff5a7a'); + if (this.chase.active && this.knight.hp <= 0) { + this.restartChase(); + } + } + + private pointNearSegment(px: number, py: number, ax: number, ay: number, bx: number, by: number, maxDist: number): boolean { + const abx = bx - ax; + const aby = by - ay; + const apx = px - ax; + const apy = py - ay; + const abLenSq = abx * abx + aby * aby || 1; + const t = clamp((apx * abx + apy * aby) / abLenSq, 0, 1); + const cx = ax + abx * t; + const cy = ay + aby * t; + return Math.hypot(px - cx, py - cy) <= maxDist; + } + private clampKnight() { this.knight.x = clamp(this.knight.x, 22, this.width - 22); this.knight.y = clamp(this.knight.y, 22, this.height - 22); @@ -597,20 +813,36 @@ export class GameEngine { this.spawnBurst(c.x, c.y, '142,247,255', 12, 110); this.spawnFloatingText(c.x, c.y - 12, `+${formatNumber(c.value)}`, '#8ef7ff'); } - if (this.crystalsInZone >= WORLD.CRYSTALS_PER_ZONE) this.advanceZone(); + if (this.crystalsInZone >= WORLD.CRYSTALS_PER_ZONE) this.finishZone(this.zone); } - private advanceZone() { - this.zone += 1; + private finishZone(completedZone: number) { + this.zone = completedZone + 1; this.crystalsInZone = 0; this.crystals = []; this.pirate = null; this.pirateSpawnTimer = 0; this.crystalSpawnTimer = 0; - if (this.zone % 10 === 0) this.startBoss(); + this.chase.active = false; + this.chase.kind = null; + this.chaseProjectiles = []; + this.fuelCellEntities = []; + + this.triggerLoreIfAny(completedZone); + this.resumeEncounterForCurrentZone(); this.save(); } + private triggerLoreIfAny(completedZone: number) { + const entry = LORE_ENTRIES.find((e) => e.zone === completedZone); + if (entry && !this.seenLore.has(entry.zone)) { + this.seenLore.add(entry.zone); + this.lore.active = true; + this.lore.entry = entry; + this.moveTarget = null; + } + } + private updateNormalMode(dt: number) { if (this.crystals.length < WORLD.MAX_CRYSTALS_ON_SCREEN) { this.crystalSpawnTimer += dt; @@ -641,6 +873,7 @@ export class GameEngine { this.knight.locked = true; this.knight.attackTimer = 0; this.moveTarget = null; + this.damageKnight(WORLD.PIRATE_CONTACT_DAMAGE); } } @@ -681,6 +914,178 @@ export class GameEngine { } else if (this.knight.attackTimer > 0.15) { this.knight.attacking = false; } + + this.updateBossAttack(dt); + } + + private updateBossAttack(dt: number) { + const boss = this.boss; + if (!boss) return; + + switch (this.bossType) { + case 'melee': { + const dist = Math.hypot(this.knight.x - boss.x, this.knight.y - boss.y); + this.bossAttackTimer += dt; + if (dist <= WORLD.CONTACT_RANGE + boss.radius * 0.5 && this.bossAttackTimer >= WORLD.BOSS_MELEE_ATTACK_INTERVAL) { + this.bossAttackTimer = 0; + this.damageKnight(WORLD.BOSS_HIT_DAMAGE); + } + break; + } + case 'missile': { + this.bossAttackTimer += dt; + if (this.bossAttackTimer >= WORLD.BOSS_MISSILE_INTERVAL) { + this.bossAttackTimer = 0; + const dx = this.knight.x - boss.x; + const dy = this.knight.y - boss.y; + const d = Math.hypot(dx, dy) || 1; + this.bossProjectiles.push({ + id: nextId(), + x: boss.x, + y: boss.y, + vx: (dx / d) * WORLD.BOSS_MISSILE_SPEED, + vy: (dy / d) * WORLD.BOSS_MISSILE_SPEED, + radius: WORLD.BOSS_MISSILE_RADIUS, + }); + } + this.bossProjectiles = this.bossProjectiles.filter((p) => { + p.x += p.vx * dt; + p.y += p.vy * dt; + if (p.x < -40 || p.x > this.width + 40 || p.y < -40 || p.y > this.height + 40) return false; + const d = Math.hypot(p.x - this.knight.x, p.y - this.knight.y); + if (d <= p.radius + WORLD.KNIGHT_HIT_RADIUS) { + this.damageKnight(WORLD.BOSS_HIT_DAMAGE); + return false; + } + return true; + }); + break; + } + case 'bomb': { + if (!this.bossBomb) { + this.bossAttackTimer += dt; + if (this.bossAttackTimer >= WORLD.BOSS_BOMB_INTERVAL) { + this.bossAttackTimer = 0; + this.bossBomb = { + x: clamp(this.knight.x + (Math.random() - 0.5) * 60, 50, this.width - 50), + y: clamp(this.knight.y + (Math.random() - 0.5) * 60, 50, this.height - 50), + radius: WORLD.BOSS_BOMB_RADIUS, + state: 'telegraph', + timer: WORLD.BOSS_BOMB_TELEGRAPH, + }; + } + } else { + const bomb = this.bossBomb; + bomb.timer -= dt; + if (bomb.state === 'telegraph' && bomb.timer <= 0) { + bomb.state = 'boom'; + bomb.timer = WORLD.BOSS_BOMB_EXPLODE_DURATION; + const d = Math.hypot(bomb.x - this.knight.x, bomb.y - this.knight.y); + if (d <= bomb.radius) this.damageKnight(WORLD.BOSS_HIT_DAMAGE); + this.spawnBurst(bomb.x, bomb.y, '255,140,60', 20, 160); + } else if (bomb.state === 'boom' && bomb.timer <= 0) { + this.bossBomb = null; + } + } + break; + } + case 'laser': { + this.bossLaserAngle += WORLD.BOSS_LASER_ROTATE_SPEED * dt; + const tipX = boss.x + Math.cos(this.bossLaserAngle) * WORLD.BOSS_LASER_LENGTH; + const tipY = boss.y + Math.sin(this.bossLaserAngle) * WORLD.BOSS_LASER_LENGTH; + const hit = this.pointNearSegment( + this.knight.x, + this.knight.y, + boss.x, + boss.y, + tipX, + tipY, + WORLD.BOSS_LASER_WIDTH / 2 + WORLD.KNIGHT_HIT_RADIUS, + ); + if (hit) this.damageKnight(WORLD.BOSS_HIT_DAMAGE); + break; + } + } + } + + private updateChase(dt: number) { + const engulfedFreeze = this.chase.kind === 'swarm' && this.chase.engulfed; + if (!engulfedFreeze) { + this.applyMoveTarget(dt); + } else { + this.knight.thrusting = false; + } + this.clampKnight(); + + this.chase.timer += dt; + + if (this.chase.kind === 'cruiser') this.updateCruiserChase(dt); + else if (this.chase.kind === 'swarm') this.updateSwarmChase(dt); + } + + private updateCruiserChase(dt: number) { + if (this.fuelCellEntities.length < WORLD.FUEL_CELL_MAX_ON_SCREEN) { + this.fuelCellSpawnTimer += dt; + if (this.fuelCellSpawnTimer >= WORLD.FUEL_CELL_SPAWN_INTERVAL) { + this.fuelCellSpawnTimer = 0; + this.spawnFuelCell(); + } + } + + this.fuelCellEntities = this.fuelCellEntities.filter((f) => { + f.rotation += f.spin * dt; + const d = Math.hypot(f.x - this.knight.x, f.y - this.knight.y); + if (d <= WORLD.COLLECT_RANGE) { + this.chase.fuelCells += 1; + this.spawnBurst(f.x, f.y, '255,90,90', 12, 110); + this.spawnFloatingText(f.x, f.y - 12, `${this.chase.fuelCells}/${WORLD.CRUISER_FUEL_CELLS_REQUIRED}`, '#ff5a5a'); + return false; + } + return true; + }); + + this.chase.attackTimer += dt; + if (this.chase.attackTimer >= WORLD.CHASE_LASER_INTERVAL) { + this.chase.attackTimer = 0; + this.fireChaseProjectile('laser', WORLD.CHASE_LASER_SPEED, WORLD.CHASE_LASER_RADIUS); + } + this.updateChaseProjectiles(dt, WORLD.CHASE_LASER_DAMAGE_RATIO); + + if (this.chase.fuelCells >= WORLD.CRUISER_FUEL_CELLS_REQUIRED) { + this.spawnBurst(this.width / 2, 70, '255,160,60', 30, 200); + this.finishZone(20); + } + } + + private updateSwarmChase(dt: number) { + if (this.chase.engulfed) { + if (this.chase.escapeMeter >= 1) { + this.chase.engulfed = false; + this.chase.escapeMeter = 0; + this.chase.engulfTimer = WORLD.SWARM_ENGULF_INTERVAL; + this.knight.invulnTimer = WORLD.HIT_INVULN_DURATION; + return; + } + this.chase.escapeMeter = Math.max(0, this.chase.escapeMeter - WORLD.SWARM_ESCAPE_DECAY_PER_SEC * dt); + return; + } + + this.chase.engulfTimer -= dt; + if (this.chase.engulfTimer <= 0) { + this.chase.engulfed = true; + this.chase.escapeMeter = 0; + } + + this.chase.attackTimer += dt; + if (this.chase.attackTimer >= WORLD.CHASE_PLASMA_INTERVAL) { + this.chase.attackTimer = 0; + this.fireChaseProjectile('plasma', WORLD.CHASE_PLASMA_SPEED, WORLD.CHASE_PLASMA_RADIUS); + } + this.updateChaseProjectiles(dt, WORLD.CHASE_PLASMA_DAMAGE_RATIO); + + if (this.chase.timer >= WORLD.SWARM_DURATION) { + this.finishZone(40); + } } private updateAsteroids(dt: number) { @@ -751,21 +1156,9 @@ export class GameEngine { if (this.boss && this.boss.hp <= 0) { const defeatedZone = this.zone; this.boss = null; - this.zone += 1; - this.crystalsInZone = 0; - this.pirateSpawnTimer = 0; - this.crystalSpawnTimer = 0; - if (this.zone % 10 === 0) this.startBoss(); - - const entry = LORE_ENTRIES.find((e) => e.zone === defeatedZone); - if (entry && !this.seenLore.has(entry.zone)) { - this.seenLore.add(entry.zone); - this.lore.active = true; - this.lore.entry = entry; - this.moveTarget = null; - } - - this.save(); + this.bossProjectiles = []; + this.bossBomb = null; + this.finishZone(defeatedZone); } } @@ -884,6 +1277,137 @@ export class GameEngine { ctx.restore(); } + private drawFuelCell(f: FuelCellEntity) { + const ctx = this.ctx; + ctx.save(); + ctx.translate(f.x, f.y); + ctx.rotate(f.rotation); + const grad = ctx.createLinearGradient(-f.radius, -f.radius, f.radius, f.radius); + grad.addColorStop(0, '#ffd0a0'); + grad.addColorStop(0.5, '#ff6a3c'); + grad.addColorStop(1, '#c0203f'); + this.polygonPath(0, 0, f.radius, 6, 0, f.jitter); + ctx.fillStyle = grad; + ctx.shadowColor = '#ff6a3c'; + ctx.shadowBlur = 12; + ctx.fill(); + ctx.shadowBlur = 0; + ctx.strokeStyle = 'rgba(255,255,255,0.6)'; + ctx.lineWidth = 1.5; + ctx.stroke(); + ctx.restore(); + } + + private drawChaseProjectiles() { + const ctx = this.ctx; + for (const p of this.chaseProjectiles) { + ctx.save(); + ctx.translate(p.x, p.y); + const angle = Math.atan2(p.vy, p.vx); + ctx.rotate(angle); + if (p.kind === 'laser') { + ctx.strokeStyle = '#ff3c5a'; + ctx.shadowColor = '#ff3c5a'; + ctx.shadowBlur = 12; + ctx.lineWidth = 4; + ctx.beginPath(); + ctx.moveTo(-p.radius * 1.8, 0); + ctx.lineTo(p.radius * 1.8, 0); + ctx.stroke(); + } else { + ctx.fillStyle = '#b45aff'; + ctx.shadowColor = '#b45aff'; + ctx.shadowBlur = 14; + ctx.beginPath(); + ctx.arc(0, 0, p.radius, 0, Math.PI * 2); + ctx.fill(); + } + ctx.shadowBlur = 0; + ctx.restore(); + } + } + + private drawChaseBackdrop() { + const ctx = this.ctx; + if (this.chase.kind === 'cruiser') { + const cx = this.width / 2; + const cy = 60; + ctx.save(); + ctx.translate(cx, cy); + ctx.beginPath(); + ctx.moveTo(-160, -10); + ctx.lineTo(160, -10); + ctx.lineTo(120, 40); + ctx.lineTo(-120, 40); + ctx.closePath(); + ctx.fillStyle = '#2a2f45'; + ctx.fill(); + ctx.strokeStyle = '#0f7a4a'; + ctx.lineWidth = 3; + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(-30, -10); + ctx.lineTo(30, -10); + ctx.lineTo(20, -40); + ctx.lineTo(-20, -40); + ctx.closePath(); + ctx.fillStyle = '#3a3f5a'; + ctx.fill(); + ctx.fillStyle = '#c0203f'; + ctx.beginPath(); + ctx.arc(0, -50, 8, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } else if (this.chase.kind === 'swarm') { + const cx = this.width / 2; + const cy = 50; + const pulse = 1 + Math.sin(this.time * 2) * 0.08; + ctx.save(); + const grad = ctx.createRadialGradient(cx, cy, 10, cx, cy, 140 * pulse); + grad.addColorStop(0, 'rgba(30,10,40,0.95)'); + grad.addColorStop(1, 'rgba(10,5,15,0)'); + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.arc(cx, cy, 140 * pulse, 0, Math.PI * 2); + ctx.fill(); + const rand = mulberry32(77); + for (let i = 0; i < 40; i++) { + const a = rand() * Math.PI * 2; + const r = rand() * 110; + ctx.beginPath(); + ctx.arc(cx + Math.cos(a) * r, cy + Math.sin(a) * r * 0.6, 1.5, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(200,120,255,0.6)'; + ctx.fill(); + } + ctx.restore(); + } + } + + private drawEngulfOverlay() { + const ctx = this.ctx; + ctx.save(); + ctx.fillStyle = 'rgba(2,2,8,0.82)'; + ctx.fillRect(0, 0, this.width, this.height); + + const cx = this.width / 2; + const cy = this.height / 2; + ctx.textAlign = 'center'; + ctx.fillStyle = '#ffdede'; + ctx.font = 'bold 26px system-ui, sans-serif'; + ctx.shadowColor = '#ff5a7a'; + ctx.shadowBlur = 16; + ctx.fillText('ТАПАЙТЕ БЫСТРО!', cx, cy - 50); + ctx.shadowBlur = 0; + + const barW = 260; + const barH = 22; + this.drawHealthBar(cx - barW / 2, cy - 10, barW, barH, this.chase.escapeMeter, '#ffb43c'); + ctx.font = '13px system-ui, sans-serif'; + ctx.fillStyle = '#c8ceec'; + ctx.fillText('Рыцарь пойман Чёрным Роем!', cx, cy + 40); + ctx.restore(); + } + private drawHealthBar(x: number, y: number, w: number, h: number, ratio: number, color: string) { const ctx = this.ctx; const r = clamp(ratio, 0, 1); @@ -895,36 +1419,203 @@ export class GameEngine { ctx.fillRect(x, y, w * r, h); } - private drawEnemy(e: EnemyEntity) { + private drawPirate(e: EnemyEntity) { const ctx = this.ctx; ctx.save(); ctx.translate(e.x, e.y); const wob = Math.sin(this.time * 4 + e.id) * 0.06; - this.polygonPath(0, 0, e.radius, e.isBoss ? 10 : 7, wob, e.jitter); - ctx.fillStyle = e.hitFlash > 0 ? '#ffffff' : e.isBoss ? '#ff5a7a' : '#7bffb0'; + this.polygonPath(0, 0, e.radius, 7, wob, e.jitter); + ctx.fillStyle = e.hitFlash > 0 ? '#ffffff' : '#7bffb0'; ctx.fill(); ctx.lineWidth = 2; - ctx.strokeStyle = e.isBoss ? '#7a0f2b' : '#0f7a4a'; + ctx.strokeStyle = '#0f7a4a'; + ctx.stroke(); + + // pirate bandana + ctx.beginPath(); + ctx.moveTo(-e.radius * 0.78, -e.radius * 0.32); + ctx.lineTo(e.radius * 0.7, -e.radius * 0.32); + ctx.lineTo(e.radius * 0.48, -e.radius * 0.78); + ctx.lineTo(-e.radius * 0.5, -e.radius * 0.78); + ctx.closePath(); + ctx.fillStyle = '#c0203f'; + ctx.fill(); + ctx.beginPath(); + ctx.moveTo(e.radius * 0.65, -e.radius * 0.4); + ctx.lineTo(e.radius * 1.05, -e.radius * 0.12); + ctx.lineTo(e.radius * 0.82, -e.radius * 0.56); + ctx.closePath(); + ctx.fillStyle = '#8f142c'; + ctx.fill(); + + // eyepatch over one eye, plain eye on the other + const eyeOffset = e.radius * 0.32; + ctx.strokeStyle = '#0b1026'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(-eyeOffset * 1.7, -e.radius * 0.32); + ctx.lineTo(-eyeOffset * 0.3, e.radius * 0.22); ctx.stroke(); ctx.fillStyle = '#0b1026'; - const eyeOffset = e.radius * 0.32; ctx.beginPath(); - ctx.arc(-eyeOffset, -2, e.radius * 0.09, 0, Math.PI * 2); + ctx.ellipse(-eyeOffset, -1, e.radius * 0.19, e.radius * 0.13, -0.2, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(eyeOffset, -2, e.radius * 0.09, 0, Math.PI * 2); ctx.fill(); ctx.restore(); - const barW = e.isBoss ? 100 : 46; + const barW = 46; const barY = e.y - e.radius - 16; - this.drawHealthBar(e.x - barW / 2, barY, barW, e.isBoss ? 10 : 6, e.hp / e.maxHp, e.isBoss ? '#ff5a7a' : '#7bffb0'); - if (e.isBoss) { - ctx.fillStyle = '#ffdede'; - ctx.font = 'bold 13px system-ui, sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText('МЕГА-ПИРАТ', e.x, barY - 8); + this.drawHealthBar(e.x - barW / 2, barY, barW, 6, e.hp / e.maxHp, '#7bffb0'); + } + + private bossColor(): string { + if (this.bossType === 'laser') return '#ff3cf0'; + if (this.bossType === 'bomb') return '#ff7a3c'; + if (this.bossType === 'missile') return '#ff5a3c'; + return '#ff5a7a'; + } + + private drawBoss(e: EnemyEntity) { + const ctx = this.ctx; + const sides = Math.min(16, 10 + (this.bossNumber - 1) * 2); + const color = this.bossColor(); + + ctx.save(); + ctx.translate(e.x, e.y); + const wob = Math.sin(this.time * 3 + e.id) * 0.05; + this.starPath(0, 0, e.radius, e.radius * 0.82, sides, wob); + ctx.fillStyle = e.hitFlash > 0 ? '#ffffff' : color; + ctx.shadowColor = color; + ctx.shadowBlur = 14 + this.bossNumber * 2; + ctx.fill(); + ctx.shadowBlur = 0; + ctx.lineWidth = 2.5; + ctx.strokeStyle = '#5a0f1f'; + ctx.stroke(); + + ctx.fillStyle = '#170206'; + const eyeOffset = e.radius * 0.3; + ctx.beginPath(); + ctx.arc(-eyeOffset, -e.radius * 0.05, e.radius * 0.1, 0, Math.PI * 2); + ctx.fill(); + ctx.beginPath(); + ctx.arc(eyeOffset, -e.radius * 0.05, e.radius * 0.1, 0, Math.PI * 2); + ctx.fill(); + + if (this.bossType === 'missile') { + ctx.fillStyle = '#3a2020'; + ctx.fillRect(-e.radius * 0.95, e.radius * 0.05, e.radius * 0.32, e.radius * 0.55); + ctx.fillRect(e.radius * 0.63, e.radius * 0.05, e.radius * 0.32, e.radius * 0.55); + } else if (this.bossType === 'bomb') { + ctx.beginPath(); + ctx.arc(0, e.radius * 0.38, e.radius * 0.28, 0, Math.PI * 2); + ctx.fillStyle = '#1c1410'; + ctx.fill(); + } else if (this.bossType === 'laser') { + ctx.beginPath(); + ctx.arc(0, 0, e.radius * 0.22, 0, Math.PI * 2); + ctx.fillStyle = '#ffffff'; + ctx.shadowColor = '#ff3cf0'; + ctx.shadowBlur = 12; + ctx.fill(); + ctx.shadowBlur = 0; } + ctx.restore(); + + const barW = 100; + const barY = e.y - e.radius - 16; + this.drawHealthBar(e.x - barW / 2, barY, barW, 10, e.hp / e.maxHp, color); + ctx.fillStyle = '#ffdede'; + ctx.font = 'bold 13px system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(`МЕГА-ПИРАТ ${this.bossNumber}`, e.x, barY - 8); + } + + private drawBossProjectiles() { + const ctx = this.ctx; + for (const p of this.bossProjectiles) { + const angle = Math.atan2(p.vy, p.vx); + ctx.save(); + ctx.translate(p.x, p.y); + ctx.rotate(angle); + ctx.beginPath(); + ctx.moveTo(p.radius * 1.6, 0); + ctx.lineTo(-p.radius, p.radius * 0.8); + ctx.lineTo(-p.radius * 0.4, 0); + ctx.lineTo(-p.radius, -p.radius * 0.8); + ctx.closePath(); + ctx.fillStyle = '#ff5a3c'; + ctx.shadowColor = '#ff5a3c'; + ctx.shadowBlur = 10; + ctx.fill(); + ctx.shadowBlur = 0; + ctx.restore(); + } + } + + private drawBossBomb() { + const bomb = this.bossBomb; + if (!bomb) return; + const ctx = this.ctx; + if (bomb.state === 'telegraph') { + const pulse = 0.55 + Math.sin(this.time * 10) * 0.2; + ctx.save(); + ctx.strokeStyle = `rgba(255,90,60,${pulse})`; + ctx.lineWidth = 3; + ctx.setLineDash([8, 6]); + ctx.beginPath(); + ctx.arc(bomb.x, bomb.y, bomb.radius, 0, Math.PI * 2); + ctx.stroke(); + ctx.setLineDash([]); + ctx.restore(); + } else { + const t = 1 - bomb.timer / WORLD.BOSS_BOMB_EXPLODE_DURATION; + const r = bomb.radius * (0.3 + t * 0.9); + const grad = ctx.createRadialGradient(bomb.x, bomb.y, 0, bomb.x, bomb.y, r); + grad.addColorStop(0, 'rgba(255,244,200,0.95)'); + grad.addColorStop(0.5, 'rgba(255,140,60,0.7)'); + grad.addColorStop(1, 'rgba(255,90,60,0)'); + ctx.save(); + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.arc(bomb.x, bomb.y, r, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + } + + private drawBossLaser() { + if (this.bossType !== 'laser' || !this.boss) return; + const ctx = this.ctx; + const boss = this.boss; + const tipX = boss.x + Math.cos(this.bossLaserAngle) * WORLD.BOSS_LASER_LENGTH; + const tipY = boss.y + Math.sin(this.bossLaserAngle) * WORLD.BOSS_LASER_LENGTH; + ctx.save(); + ctx.strokeStyle = 'rgba(255,60,240,0.85)'; + ctx.lineWidth = WORLD.BOSS_LASER_WIDTH; + ctx.shadowColor = '#ff3cf0'; + ctx.shadowBlur = 20; + ctx.beginPath(); + ctx.moveTo(boss.x, boss.y); + ctx.lineTo(tipX, tipY); + ctx.stroke(); + ctx.shadowBlur = 0; + ctx.lineWidth = WORLD.BOSS_LASER_WIDTH * 0.35; + ctx.strokeStyle = 'rgba(255,255,255,0.9)'; + ctx.beginPath(); + ctx.moveTo(boss.x, boss.y); + ctx.lineTo(tipX, tipY); + ctx.stroke(); + ctx.restore(); + } + + private drawKnightHealthBar() { + const barW = 42; + const barY = this.knight.y - 34; + const ratio = this.knight.hp / this.knightMaxHp(); + this.drawHealthBar(this.knight.x - barW / 2, barY, barW, 5, ratio, ratio > 0.4 ? '#7bffb0' : '#ff5a7a'); } private drawDroids() { @@ -1112,6 +1803,41 @@ export class GameEngine { ctx.restore(); } + private drawDestinationGalaxy(cx: number, cy: number, progress: number) { + if (progress <= 0) return; + const ctx = this.ctx; + const gx = cx; + const gy = cy - this.height * 0.22; + const radius = 10 + progress * 210; + ctx.save(); + ctx.globalAlpha = clamp(progress * 1.3, 0, 1); + const grad = ctx.createRadialGradient(gx, gy, 0, gx, gy, radius); + grad.addColorStop(0, 'rgba(255,246,214,0.95)'); + grad.addColorStop(0.35, 'rgba(155,91,255,0.55)'); + grad.addColorStop(1, 'rgba(91,140,255,0)'); + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.arc(gx, gy, radius, 0, Math.PI * 2); + ctx.fill(); + + const arms = 3; + for (let a = 0; a < arms; a++) { + const baseAngle = (Math.PI * 2 * a) / arms + this.time * 0.3; + for (let i = 0; i < 14; i++) { + const tt = i / 14; + const ang = baseAngle + tt * 3.2; + const r = radius * 0.15 + tt * radius * 0.75; + const px = gx + Math.cos(ang) * r; + const py = gy + Math.sin(ang) * r * 0.55; + ctx.beginPath(); + ctx.arc(px, py, 1 + (1 - tt) * 2, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(255,255,255,0.8)'; + ctx.fill(); + } + } + ctx.restore(); + } + private renderCutscene() { const ctx = this.ctx; const t = this.cutscene.timer; @@ -1144,6 +1870,9 @@ export class GameEngine { } ctx.globalAlpha = 1; + const galaxyProgress = clamp((t - 2.5) / 7, 0, 1); + this.drawDestinationGalaxy(cx, cy, galaxyProgress); + this.drawRocket(cx, cy + this.height * 0.12, intensity); } @@ -1153,15 +1882,29 @@ export class GameEngine { return; } this.drawBackground(); + if (this.chase.active) this.drawChaseBackdrop(); for (const c of this.crystals) this.drawCrystal(c); + if (this.chase.active && this.chase.kind === 'cruiser') { + for (const f of this.fuelCellEntities) this.drawFuelCell(f); + } this.drawAsteroids(); this.drawGoldenAsteroid(); - if (this.pirate) this.drawEnemy(this.pirate); - if (this.boss) this.drawEnemy(this.boss); + if (this.chase.active) this.drawChaseProjectiles(); + if (this.pirate) this.drawPirate(this.pirate); + if (this.boss) { + this.drawBossLaser(); + this.drawBossBomb(); + this.drawBossProjectiles(); + this.drawBoss(this.boss); + } this.drawDroids(); this.drawParticles(); this.drawKnight(); + this.drawKnightHealthBar(); this.drawFloatingTexts(); + if (this.chase.active && this.chase.kind === 'swarm' && this.chase.engulfed) { + this.drawEngulfOverlay(); + } } // ------------------------------------------------------------------ loop @@ -1196,12 +1939,14 @@ export class GameEngine { speed: this.cost('speed'), damage: this.cost('damage'), droid: this.cost('droid'), + vitality: this.cost('vitality'), }, canAfford: { crystal: this.score >= this.cost('crystal'), speed: this.score >= this.cost('speed'), damage: this.score >= this.cost('damage'), droid: this.score >= this.cost('droid'), + vitality: this.score >= this.cost('vitality'), }, droidCount: this.droids.length, canPrestige: this.zone >= WORLD.PRESTIGE_MIN_ZONE, @@ -1209,6 +1954,16 @@ export class GameEngine { cutsceneActive: this.cutscene.active, loreActive: this.lore.active, loreEntry: this.lore.entry, + knightHp: this.knight.hp, + knightMaxHp: this.knightMaxHp(), + musicMuted: this.music.isMuted(), + chaseActive: this.chase.active, + chaseKind: this.chase.kind, + chaseFuelCells: this.chase.fuelCells, + chaseFuelRequired: WORLD.CRUISER_FUEL_CELLS_REQUIRED, + chaseTimeRemaining: this.chase.kind === 'swarm' ? Math.max(0, WORLD.SWARM_DURATION - this.chase.timer) : 0, + chaseEngulfed: this.chase.engulfed, + chaseEscapeMeter: this.chase.escapeMeter, }; } @@ -1243,6 +1998,7 @@ export class GameEngine { speed: data.levels?.speed ?? 1, damage: data.levels?.damage ?? 1, droid: data.levels?.droid ?? 1, + vitality: data.levels?.vitality ?? 1, }; this.seenLore = new Set(data.seenLore ?? []); } catch { diff --git a/src/game/constants.ts b/src/game/constants.ts index 4e8d388..f3b0811 100644 --- a/src/game/constants.ts +++ b/src/game/constants.ts @@ -4,8 +4,8 @@ export const WORLD = { CRYSTALS_PER_ZONE: 10, MAX_CRYSTALS_ON_SCREEN: 6, CRYSTAL_SPAWN_INTERVAL: 1.1, - PIRATE_SPAWN_INTERVAL: 15, - BOSS_HP_MULT: 5, + PIRATE_SPAWN_INTERVAL: 3, + BOSS_HP_MULT: 3, CONTACT_RANGE: 46, COLLECT_RANGE: 26, PIRATE_HP_ZONE_MULT: 1.2, @@ -34,6 +34,39 @@ export const WORLD = { GOLDEN_ASTEROID_REWARD_MULT: 20, SUPERNOVA_DURATION: 10, SUPERNOVA_DARK_MATTER_GAIN: 1, + BASE_KNIGHT_MAX_HP: 10, + PIRATE_CONTACT_DAMAGE: 1, + BOSS_HIT_DAMAGE: 3, + HIT_INVULN_DURATION: 0.6, + BOSS_BASE_RADIUS: 48, + BOSS_RADIUS_PER_TIER: 6, + BOSS_MAX_RADIUS: 96, + BOSS_MELEE_ATTACK_INTERVAL: 1.3, + BOSS_MISSILE_INTERVAL: 2.4, + BOSS_MISSILE_SPEED: 150, + BOSS_MISSILE_RADIUS: 9, + BOSS_BOMB_INTERVAL: 3.4, + BOSS_BOMB_TELEGRAPH: 1.3, + BOSS_BOMB_EXPLODE_DURATION: 0.35, + BOSS_BOMB_RADIUS: 62, + BOSS_LASER_ROTATE_SPEED: 1.6, + BOSS_LASER_LENGTH: 420, + BOSS_LASER_WIDTH: 16, + CHASE_LASER_INTERVAL: 2, + CHASE_LASER_SPEED: 260, + CHASE_LASER_RADIUS: 8, + CHASE_LASER_DAMAGE_RATIO: 0.15, + CHASE_PLASMA_INTERVAL: 1.6, + CHASE_PLASMA_SPEED: 220, + CHASE_PLASMA_RADIUS: 12, + CHASE_PLASMA_DAMAGE_RATIO: 0.2, + CRUISER_FUEL_CELLS_REQUIRED: 10, + FUEL_CELL_MAX_ON_SCREEN: 4, + FUEL_CELL_SPAWN_INTERVAL: 1.4, + SWARM_DURATION: 40, + SWARM_ENGULF_INTERVAL: 10, + SWARM_ESCAPE_FILL_PER_TAP: 0.22, + SWARM_ESCAPE_DECAY_PER_SEC: 0.12, } as const; export const UPGRADES: Record = { @@ -69,6 +102,14 @@ export const UPGRADES: Record { + const ctx = this.ctx; + const gain = this.gain; + if (!ctx || !gain) return; + + const startTime = ctx.currentTime + 0.05; + let t = startTime; + for (const [freq, dur] of MELODY) { + this.playNote(freq, t, dur * 0.85, 'square', 0.55); + t += dur; + } + const loopDuration = t - startTime; + + let bt = startTime; + while (bt < startTime + loopDuration) { + for (const [freq, dur] of BASS) { + if (bt >= startTime + loopDuration) break; + this.playNote(freq, bt, dur * 0.9, 'triangle', 0.7); + bt += dur; + } + } + + this.loopHandle = window.setTimeout(() => { + this.loopHandle = null; + this.scheduleLoop(); + }, loopDuration * 1000); + }; + + private playNote(freq: number, time: number, dur: number, type: OscillatorType, level: number) { + const ctx = this.ctx; + const gain = this.gain; + if (!ctx || !gain) return; + const osc = ctx.createOscillator(); + const noteGain = ctx.createGain(); + osc.type = type; + osc.frequency.value = freq; + noteGain.gain.setValueAtTime(0, time); + noteGain.gain.linearRampToValueAtTime(level, time + 0.02); + noteGain.gain.linearRampToValueAtTime(0, time + dur); + osc.connect(noteGain); + noteGain.connect(gain); + osc.start(time); + osc.stop(time + dur + 0.05); + } + + toggleMute(): boolean { + this.muted = !this.muted; + if (this.gain) this.gain.gain.value = this.muted ? 0 : 0.09; + return this.muted; + } + + isMuted(): boolean { + return this.muted; + } + + stop() { + if (this.loopHandle !== null) { + window.clearTimeout(this.loopHandle); + this.loopHandle = null; + } + this.ctx?.close(); + this.ctx = null; + this.gain = null; + } +} diff --git a/src/game/types.ts b/src/game/types.ts index 3fb75e4..985600d 100644 --- a/src/game/types.ts +++ b/src/game/types.ts @@ -1,4 +1,6 @@ -export type UpgradeKey = 'crystal' | 'speed' | 'damage' | 'droid'; +export type UpgradeKey = 'crystal' | 'speed' | 'damage' | 'droid' | 'vitality'; + +export type BossType = 'melee' | 'missile' | 'bomb' | 'laser'; export interface CrystalEntity { id: number; @@ -56,6 +58,45 @@ export interface GoldenAsteroidEntity { jitter: number[]; } +export interface BossProjectile { + id: number; + x: number; + y: number; + vx: number; + vy: number; + radius: number; +} + +export interface BossBomb { + x: number; + y: number; + radius: number; + state: 'telegraph' | 'boom'; + timer: number; +} + +export type ChaseKind = 'cruiser' | 'swarm'; + +export interface FuelCellEntity { + id: number; + x: number; + y: number; + radius: number; + rotation: number; + spin: number; + jitter: number[]; +} + +export interface ChaseProjectile { + id: number; + kind: 'laser' | 'plasma'; + x: number; + y: number; + vx: number; + vy: number; + radius: number; +} + export interface Particle { x: number; y: number; @@ -104,4 +145,14 @@ export interface GameSnapshot { cutsceneActive: boolean; loreActive: boolean; loreEntry: LoreEntry | null; + knightHp: number; + knightMaxHp: number; + musicMuted: boolean; + chaseActive: boolean; + chaseKind: ChaseKind | null; + chaseFuelCells: number; + chaseFuelRequired: number; + chaseTimeRemaining: number; + chaseEngulfed: boolean; + chaseEscapeMeter: number; }