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;
}