From 0788f4269990fedb8e8ab601807c116e2a742ffb Mon Sep 17 00:00:00 2001 From: "azykov@mail.ru" Date: Sat, 5 Sep 2026 12:13:17 +0300 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01D5SuZW1XGCkahPX7cHMoRP --- src/App.css | 86 ++++++++++++++++++++++++++++++++++++ src/App.tsx | 4 ++ src/components/LoreModal.tsx | 16 +++++++ src/components/ShopPanel.tsx | 2 +- src/game/GameEngine.ts | 45 +++++++++++++++---- src/game/constants.ts | 22 ++++++++- src/game/types.ts | 8 ++++ 7 files changed, 172 insertions(+), 11 deletions(-) create mode 100644 src/components/LoreModal.tsx diff --git a/src/App.css b/src/App.css index 61b8267..6af49a5 100644 --- a/src/App.css +++ b/src/App.css @@ -305,3 +305,89 @@ 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); + } +} diff --git a/src/App.tsx b/src/App.tsx index 506d225..819de61 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ import { useRef } from 'react'; import { BossBanner } from './components/BossBanner'; import { Hud } from './components/Hud'; +import { LoreModal } from './components/LoreModal'; import { ShopPanel } from './components/ShopPanel'; import { SupernovaOverlay } from './components/SupernovaOverlay'; import { useGameEngine } from './hooks/useGameEngine'; @@ -17,6 +18,9 @@ function App() { {snapshot && !snapshot.cutsceneActive && } {snapshot?.bossActive && !snapshot.cutsceneActive && } {snapshot?.cutsceneActive && } + {snapshot?.loreActive && snapshot.loreEntry && ( + engine.current?.dismissLore()} /> + )} {snapshot && ( void }) { + return ( +
+
e.stopPropagation()}> +
Архив Данных
+
{entry.title}
+

{entry.text}

+ +
+
+ ); +} diff --git a/src/components/ShopPanel.tsx b/src/components/ShopPanel.tsx index d4a4b28..91ec2ee 100644 --- a/src/components/ShopPanel.tsx +++ b/src/components/ShopPanel.tsx @@ -13,7 +13,7 @@ export function ShopPanel({ onBuy: (key: UpgradeKey) => void; onPrestige: () => void; }) { - const locked = snapshot.cutsceneActive; + const locked = snapshot.cutsceneActive || snapshot.loreActive; return (
{KEYS.map((key) => { diff --git a/src/game/GameEngine.ts b/src/game/GameEngine.ts index 3584c8f..5b44708 100644 --- a/src/game/GameEngine.ts +++ b/src/game/GameEngine.ts @@ -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 { makeJitter, mulberry32 } from './random'; import type { @@ -9,6 +9,7 @@ import type { FloatingText, GameSnapshot, GoldenAsteroidEntity, + LoreEntry, Particle, UpgradeKey, } from './types'; @@ -21,6 +22,7 @@ interface SaveData { crystalsInZone: number; darkMatter: number; levels: Record; + seenLore?: number[]; } let idCounter = 1; @@ -79,6 +81,8 @@ export class GameEngine { private moveTarget: { x: number; y: number } | null = null; private cutscene = { active: false, timer: 0 }; + private lore: { active: boolean; entry: LoreEntry | null } = { active: false, entry: null }; + private seenLore = new Set(); private knight: KnightState = { x: 0, @@ -173,7 +177,7 @@ export class GameEngine { // ------------------------------------------------------------------ input private handlePointerDown(e: PointerEvent) { - if (this.cutscene.active) return; + if (this.cutscene.active || this.lore.active) return; const rect = this.canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; @@ -230,7 +234,7 @@ export class GameEngine { } buyUpgrade(key: UpgradeKey): boolean { - if (this.cutscene.active) return false; + if (this.cutscene.active || this.lore.active) return false; const cost = this.cost(key); if (this.score < cost) return false; this.score -= cost; @@ -246,7 +250,7 @@ export class GameEngine { } 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.timer = 0; this.moveTarget = null; @@ -254,6 +258,13 @@ export class GameEngine { return true; } + dismissLore(): void { + if (!this.lore.active) return; + this.lore.active = false; + this.lore.entry = null; + this.notify(); + } + private applyPrestigeReset() { this.darkMatter += WORLD.SUPERNOVA_DARK_MATTER_GAIN; this.score = 0; @@ -479,6 +490,8 @@ export class GameEngine { if (this.cutscene.active) { this.updateCutscene(dt); + } else if (this.lore.active) { + // frozen: the story archive modal pauses the world until dismissed } else { this.updateAsteroids(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; } - this.updateParticles(dt); - 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.lore.active) { + this.updateParticles(dt); + 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); + } this.snapshotTimer += dt; if (this.snapshotTimer > 0.12) { @@ -734,12 +749,22 @@ export class GameEngine { this.knight.locked = false; } 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(); } } @@ -1182,6 +1207,8 @@ export class GameEngine { canPrestige: this.zone >= WORLD.PRESTIGE_MIN_ZONE, prestigeGain: WORLD.SUPERNOVA_DARK_MATTER_GAIN, cutsceneActive: this.cutscene.active, + loreActive: this.lore.active, + loreEntry: this.lore.entry, }; } @@ -1194,6 +1221,7 @@ export class GameEngine { crystalsInZone: this.crystalsInZone, darkMatter: this.darkMatter, levels: { ...this.levels }, + seenLore: Array.from(this.seenLore), }; localStorage.setItem(SAVE_KEY, JSON.stringify(data)); } catch { @@ -1216,6 +1244,7 @@ export class GameEngine { damage: data.levels?.damage ?? 1, droid: data.levels?.droid ?? 1, }; + this.seenLore = new Set(data.seenLore ?? []); } catch { // corrupted save, start fresh } diff --git a/src/game/constants.ts b/src/game/constants.ts index 7d691f0..4e8d388 100644 --- a/src/game/constants.ts +++ b/src/game/constants.ts @@ -1,4 +1,4 @@ -import type { UpgradeKey } from './types'; +import type { LoreEntry, UpgradeKey } from './types'; export const WORLD = { CRYSTALS_PER_ZONE: 10, @@ -8,7 +8,7 @@ export const WORLD = { BOSS_HP_MULT: 5, CONTACT_RANGE: 46, COLLECT_RANGE: 26, - PIRATE_HP_ZONE_MULT: 1.4, + PIRATE_HP_ZONE_MULT: 1.2, CRYSTAL_VALUE_ZONE_MULT: 1.25, BASE_PIRATE_HP: 24, 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: 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: 'Архив Данных: Анализ противника завершен. Пираты-Желейки — это биомеханические дроны, созданные заброшенными автоматическими заводами Тёмной туманности. У них нет разума, их цель — поглощать энергию Хроно-Кристаллов, чтобы строить новые армии. Их нужно остановить любой ценой!', + }, +]; diff --git a/src/game/types.ts b/src/game/types.ts index 7da624e..3fb75e4 100644 --- a/src/game/types.ts +++ b/src/game/types.ts @@ -77,6 +77,12 @@ export interface FloatingText { color: string; } +export interface LoreEntry { + zone: number; + title: string; + text: string; +} + export interface GameSnapshot { score: number; zone: number; @@ -96,4 +102,6 @@ export interface GameSnapshot { canPrestige: boolean; prestigeGain: number; cutsceneActive: boolean; + loreActive: boolean; + loreEntry: LoreEntry | null; }