Add lore archive system and rebalance pirate HP scaling

Boss defeats at zones 10/20/30 now pause the game and reveal a
one-time Data Archive story entry. Also lower per-zone pirate HP
growth from 1.4x to 1.2x.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D5SuZW1XGCkahPX7cHMoRP
This commit is contained in:
azykov@mail.ru
2026-09-05 12:13:17 +03:00
co-authored by Claude Sonnet 5
parent cbd547944e
commit 0788f42699
7 changed files with 172 additions and 11 deletions
+86
View File
@@ -305,3 +305,89 @@
transform: scale(1.05); transform: scale(1.05);
} }
} }
/* -------------------------------------------------------------- lore modal */
.lore-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(3, 4, 12, 0.72);
backdrop-filter: blur(2px);
animation: lore-backdrop-in 0.25s ease-out both;
}
.lore-panel {
max-width: 480px;
width: 100%;
padding: 22px 24px;
border-radius: 14px;
background: linear-gradient(160deg, #12162e, #0a0c1c);
border: 1px solid rgba(91, 140, 255, 0.4);
box-shadow: 0 0 32px rgba(91, 140, 255, 0.25), 0 20px 60px rgba(0, 0, 0, 0.5);
animation: lore-panel-in 0.3s cubic-bezier(0.2, 0.8, 0.3, 1) both;
}
.lore-kicker {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #7fd9ff;
margin-bottom: 6px;
}
.lore-title {
font-size: 19px;
font-weight: 800;
color: #eef1ff;
margin-bottom: 12px;
}
.lore-text {
font-size: 14px;
line-height: 1.55;
color: #c8ceec;
margin: 0 0 20px;
}
.lore-btn {
display: block;
margin-left: auto;
padding: 9px 20px;
border-radius: 8px;
border: 1px solid rgba(91, 140, 255, 0.5);
background: linear-gradient(135deg, #2d3d7a, #1a2350);
color: #eef1ff;
font: inherit;
font-weight: 700;
cursor: pointer;
transition: transform 0.1s, border-color 0.15s;
}
.lore-btn:hover {
border-color: #7fd9ff;
transform: translateY(-1px);
}
@keyframes lore-backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes lore-panel-in {
from {
opacity: 0;
transform: translateY(12px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
+4
View File
@@ -1,6 +1,7 @@
import { useRef } from 'react'; import { useRef } from 'react';
import { BossBanner } from './components/BossBanner'; import { BossBanner } from './components/BossBanner';
import { Hud } from './components/Hud'; import { Hud } from './components/Hud';
import { LoreModal } from './components/LoreModal';
import { ShopPanel } from './components/ShopPanel'; import { ShopPanel } from './components/ShopPanel';
import { SupernovaOverlay } from './components/SupernovaOverlay'; import { SupernovaOverlay } from './components/SupernovaOverlay';
import { useGameEngine } from './hooks/useGameEngine'; import { useGameEngine } from './hooks/useGameEngine';
@@ -17,6 +18,9 @@ function App() {
{snapshot && !snapshot.cutsceneActive && <Hud snapshot={snapshot} />} {snapshot && !snapshot.cutsceneActive && <Hud snapshot={snapshot} />}
{snapshot?.bossActive && !snapshot.cutsceneActive && <BossBanner snapshot={snapshot} />} {snapshot?.bossActive && !snapshot.cutsceneActive && <BossBanner snapshot={snapshot} />}
{snapshot?.cutsceneActive && <SupernovaOverlay />} {snapshot?.cutsceneActive && <SupernovaOverlay />}
{snapshot?.loreActive && snapshot.loreEntry && (
<LoreModal entry={snapshot.loreEntry} onDismiss={() => engine.current?.dismissLore()} />
)}
</div> </div>
{snapshot && ( {snapshot && (
<ShopPanel <ShopPanel
+16
View File
@@ -0,0 +1,16 @@
import type { LoreEntry } from '../game/types';
export function LoreModal({ entry, onDismiss }: { entry: LoreEntry; onDismiss: () => void }) {
return (
<div className="lore-overlay" onClick={onDismiss}>
<div className="lore-panel" onClick={(e) => e.stopPropagation()}>
<div className="lore-kicker">Архив Данных</div>
<div className="lore-title">{entry.title}</div>
<p className="lore-text">{entry.text}</p>
<button type="button" className="lore-btn" onClick={onDismiss}>
Продолжить
</button>
</div>
</div>
);
}
+1 -1
View File
@@ -13,7 +13,7 @@ export function ShopPanel({
onBuy: (key: UpgradeKey) => void; onBuy: (key: UpgradeKey) => void;
onPrestige: () => void; onPrestige: () => void;
}) { }) {
const locked = snapshot.cutsceneActive; const locked = snapshot.cutsceneActive || snapshot.loreActive;
return ( return (
<div className="shop-panel"> <div className="shop-panel">
{KEYS.map((key) => { {KEYS.map((key) => {
+37 -8
View File
@@ -1,4 +1,4 @@
import { WORLD, UPGRADES, DROID_TIERS, type DroidTierConfig } from './constants'; import { WORLD, UPGRADES, DROID_TIERS, LORE_ENTRIES, type DroidTierConfig } from './constants';
import { formatNumber } from './format'; import { formatNumber } from './format';
import { makeJitter, mulberry32 } from './random'; import { makeJitter, mulberry32 } from './random';
import type { import type {
@@ -9,6 +9,7 @@ import type {
FloatingText, FloatingText,
GameSnapshot, GameSnapshot,
GoldenAsteroidEntity, GoldenAsteroidEntity,
LoreEntry,
Particle, Particle,
UpgradeKey, UpgradeKey,
} from './types'; } from './types';
@@ -21,6 +22,7 @@ interface SaveData {
crystalsInZone: number; crystalsInZone: number;
darkMatter: number; darkMatter: number;
levels: Record<UpgradeKey, number>; levels: Record<UpgradeKey, number>;
seenLore?: number[];
} }
let idCounter = 1; let idCounter = 1;
@@ -79,6 +81,8 @@ export class GameEngine {
private moveTarget: { x: number; y: number } | null = null; private moveTarget: { x: number; y: number } | null = null;
private cutscene = { active: false, timer: 0 }; private cutscene = { active: false, timer: 0 };
private lore: { active: boolean; entry: LoreEntry | null } = { active: false, entry: null };
private seenLore = new Set<number>();
private knight: KnightState = { private knight: KnightState = {
x: 0, x: 0,
@@ -173,7 +177,7 @@ export class GameEngine {
// ------------------------------------------------------------------ input // ------------------------------------------------------------------ input
private handlePointerDown(e: PointerEvent) { private handlePointerDown(e: PointerEvent) {
if (this.cutscene.active) return; if (this.cutscene.active || this.lore.active) return;
const rect = this.canvas.getBoundingClientRect(); const rect = this.canvas.getBoundingClientRect();
const x = e.clientX - rect.left; const x = e.clientX - rect.left;
const y = e.clientY - rect.top; const y = e.clientY - rect.top;
@@ -230,7 +234,7 @@ export class GameEngine {
} }
buyUpgrade(key: UpgradeKey): boolean { buyUpgrade(key: UpgradeKey): boolean {
if (this.cutscene.active) return false; if (this.cutscene.active || this.lore.active) return false;
const cost = this.cost(key); const cost = this.cost(key);
if (this.score < cost) return false; if (this.score < cost) return false;
this.score -= cost; this.score -= cost;
@@ -246,7 +250,7 @@ export class GameEngine {
} }
prestige(): boolean { prestige(): boolean {
if (this.cutscene.active || this.zone < WORLD.PRESTIGE_MIN_ZONE) return false; if (this.cutscene.active || this.lore.active || this.zone < WORLD.PRESTIGE_MIN_ZONE) return false;
this.cutscene.active = true; this.cutscene.active = true;
this.cutscene.timer = 0; this.cutscene.timer = 0;
this.moveTarget = null; this.moveTarget = null;
@@ -254,6 +258,13 @@ export class GameEngine {
return true; return true;
} }
dismissLore(): void {
if (!this.lore.active) return;
this.lore.active = false;
this.lore.entry = null;
this.notify();
}
private applyPrestigeReset() { private applyPrestigeReset() {
this.darkMatter += WORLD.SUPERNOVA_DARK_MATTER_GAIN; this.darkMatter += WORLD.SUPERNOVA_DARK_MATTER_GAIN;
this.score = 0; this.score = 0;
@@ -479,6 +490,8 @@ export class GameEngine {
if (this.cutscene.active) { if (this.cutscene.active) {
this.updateCutscene(dt); this.updateCutscene(dt);
} else if (this.lore.active) {
// frozen: the story archive modal pauses the world until dismissed
} else { } else {
this.updateAsteroids(dt); this.updateAsteroids(dt);
if (this.boss) this.updateBossMode(dt); if (this.boss) this.updateBossMode(dt);
@@ -488,10 +501,12 @@ export class GameEngine {
for (const c of this.crystals) c.rotation += c.spin * dt; for (const c of this.crystals) c.rotation += c.spin * dt;
} }
this.updateParticles(dt); if (!this.lore.active) {
if (this.pirate && this.pirate.hitFlash > 0) this.pirate.hitFlash = Math.max(0, this.pirate.hitFlash - dt * 3); this.updateParticles(dt);
if (this.boss && this.boss.hitFlash > 0) this.boss.hitFlash = Math.max(0, this.boss.hitFlash - dt * 3); if (this.pirate && this.pirate.hitFlash > 0) this.pirate.hitFlash = Math.max(0, this.pirate.hitFlash - dt * 3);
if (this.knight.hitFlash > 0) this.knight.hitFlash = Math.max(0, this.knight.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);
}
this.snapshotTimer += dt; this.snapshotTimer += dt;
if (this.snapshotTimer > 0.12) { if (this.snapshotTimer > 0.12) {
@@ -734,12 +749,22 @@ export class GameEngine {
this.knight.locked = false; this.knight.locked = false;
} }
if (this.boss && this.boss.hp <= 0) { if (this.boss && this.boss.hp <= 0) {
const defeatedZone = this.zone;
this.boss = null; this.boss = null;
this.zone += 1; this.zone += 1;
this.crystalsInZone = 0; this.crystalsInZone = 0;
this.pirateSpawnTimer = 0; this.pirateSpawnTimer = 0;
this.crystalSpawnTimer = 0; this.crystalSpawnTimer = 0;
if (this.zone % 10 === 0) this.startBoss(); 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.save();
} }
} }
@@ -1182,6 +1207,8 @@ export class GameEngine {
canPrestige: this.zone >= WORLD.PRESTIGE_MIN_ZONE, canPrestige: this.zone >= WORLD.PRESTIGE_MIN_ZONE,
prestigeGain: WORLD.SUPERNOVA_DARK_MATTER_GAIN, prestigeGain: WORLD.SUPERNOVA_DARK_MATTER_GAIN,
cutsceneActive: this.cutscene.active, cutsceneActive: this.cutscene.active,
loreActive: this.lore.active,
loreEntry: this.lore.entry,
}; };
} }
@@ -1194,6 +1221,7 @@ export class GameEngine {
crystalsInZone: this.crystalsInZone, crystalsInZone: this.crystalsInZone,
darkMatter: this.darkMatter, darkMatter: this.darkMatter,
levels: { ...this.levels }, levels: { ...this.levels },
seenLore: Array.from(this.seenLore),
}; };
localStorage.setItem(SAVE_KEY, JSON.stringify(data)); localStorage.setItem(SAVE_KEY, JSON.stringify(data));
} catch { } catch {
@@ -1216,6 +1244,7 @@ export class GameEngine {
damage: data.levels?.damage ?? 1, damage: data.levels?.damage ?? 1,
droid: data.levels?.droid ?? 1, droid: data.levels?.droid ?? 1,
}; };
this.seenLore = new Set(data.seenLore ?? []);
} catch { } catch {
// corrupted save, start fresh // corrupted save, start fresh
} }
+20 -2
View File
@@ -1,4 +1,4 @@
import type { UpgradeKey } from './types'; import type { LoreEntry, UpgradeKey } from './types';
export const WORLD = { export const WORLD = {
CRYSTALS_PER_ZONE: 10, CRYSTALS_PER_ZONE: 10,
@@ -8,7 +8,7 @@ export const WORLD = {
BOSS_HP_MULT: 5, BOSS_HP_MULT: 5,
CONTACT_RANGE: 46, CONTACT_RANGE: 46,
COLLECT_RANGE: 26, COLLECT_RANGE: 26,
PIRATE_HP_ZONE_MULT: 1.4, PIRATE_HP_ZONE_MULT: 1.2,
CRYSTAL_VALUE_ZONE_MULT: 1.25, CRYSTAL_VALUE_ZONE_MULT: 1.25,
BASE_PIRATE_HP: 24, BASE_PIRATE_HP: 24,
BASE_PIRATE_SPEED: 72, BASE_PIRATE_SPEED: 72,
@@ -87,3 +87,21 @@ export const DROID_TIERS: DroidTierConfig[] = [
{ minLevel: 10, radius: 15, spikes: 10, color: '#ff5ad1', stroke: '#8a1b6e', laserWidth: 4, glow: 12 }, { minLevel: 10, radius: 15, spikes: 10, color: '#ff5ad1', stroke: '#8a1b6e', laserWidth: 4, glow: 12 },
{ minLevel: 20, radius: 19, spikes: 12, color: '#ffffff', stroke: '#c81b6e', laserWidth: 6, glow: 20 }, { minLevel: 20, radius: 19, spikes: 12, color: '#ffffff', stroke: '#c81b6e', laserWidth: 6, glow: 20 },
]; ];
export const LORE_ENTRIES: LoreEntry[] = [
{
zone: 10,
title: 'Кто такой Рыцарь',
text: 'Архив Данных: Сканирование памяти завершено. Этот Рыцарь — последний уцелевший пилот из древнего Ордена Звёздных Защитников. Его цивилизация исчезла много веков назад, но его высокотехнологичный скафандр сохранил пилота в режиме анабиоза, чтобы он исполнил финальную миссию.',
},
{
zone: 20,
title: 'Что за кристаллы',
text: 'Архив Данных: Обнаружена информация о кристаллах. Это не просто камни, а Хроно-Кристаллы — спрессованная чистая энергия погибших звёзд. В них записана история и координаты древней столицы галактики. Рыцарь собирает их, чтобы запустить Главный Звёздный Реактор.',
},
{
zone: 30,
title: 'Кто такие Пираты',
text: 'Архив Данных: Анализ противника завершен. Пираты-Желейки — это биомеханические дроны, созданные заброшенными автоматическими заводами Тёмной туманности. У них нет разума, их цель — поглощать энергию Хроно-Кристаллов, чтобы строить новые армии. Их нужно остановить любой ценой!',
},
];
+8
View File
@@ -77,6 +77,12 @@ export interface FloatingText {
color: string; color: string;
} }
export interface LoreEntry {
zone: number;
title: string;
text: string;
}
export interface GameSnapshot { export interface GameSnapshot {
score: number; score: number;
zone: number; zone: number;
@@ -96,4 +102,6 @@ export interface GameSnapshot {
canPrestige: boolean; canPrestige: boolean;
prestigeGain: number; prestigeGain: number;
cutsceneActive: boolean; cutsceneActive: boolean;
loreActive: boolean;
loreEntry: LoreEntry | null;
} }