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:
co-authored by
Claude Sonnet 5
parent
cbd547944e
commit
0788f42699
+86
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 && <Hud snapshot={snapshot} />}
|
||||
{snapshot?.bossActive && !snapshot.cutsceneActive && <BossBanner snapshot={snapshot} />}
|
||||
{snapshot?.cutsceneActive && <SupernovaOverlay />}
|
||||
{snapshot?.loreActive && snapshot.loreEntry && (
|
||||
<LoreModal entry={snapshot.loreEntry} onDismiss={() => engine.current?.dismissLore()} />
|
||||
)}
|
||||
</div>
|
||||
{snapshot && (
|
||||
<ShopPanel
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export function ShopPanel({
|
||||
onBuy: (key: UpgradeKey) => void;
|
||||
onPrestige: () => void;
|
||||
}) {
|
||||
const locked = snapshot.cutsceneActive;
|
||||
const locked = snapshot.cutsceneActive || snapshot.loreActive;
|
||||
return (
|
||||
<div className="shop-panel">
|
||||
{KEYS.map((key) => {
|
||||
|
||||
+33
-4
@@ -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<UpgradeKey, number>;
|
||||
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<number>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
+20
-2
@@ -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: 'Архив Данных: Анализ противника завершен. Пираты-Желейки — это биомеханические дроны, созданные заброшенными автоматическими заводами Тёмной туманности. У них нет разума, их цель — поглощать энергию Хроно-Кристаллов, чтобы строить новые армии. Их нужно остановить любой ценой!',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user