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);
|
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 { 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
|
||||||
|
|||||||
@@ -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;
|
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) => {
|
||||||
|
|||||||
+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 { 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!this.lore.active) {
|
||||||
this.updateParticles(dt);
|
this.updateParticles(dt);
|
||||||
if (this.pirate && this.pirate.hitFlash > 0) this.pirate.hitFlash = Math.max(0, this.pirate.hitFlash - dt * 3);
|
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.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.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
@@ -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: 'Архив Данных: Анализ противника завершен. Пираты-Желейки — это биомеханические дроны, созданные заброшенными автоматическими заводами Тёмной туманности. У них нет разума, их цель — поглощать энергию Хроно-Кристаллов, чтобы строить новые армии. Их нужно остановить любой ценой!',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user