Add chase drift, main/pause menus, and unlockable skins

Zones 20 and 40 now push everything rightward for the first 20
seconds of their chase, forcing the player to tap left to stay on
screen. Added a main menu (new game / continue / skins), an in-game
pause menu, and a skins system that unlocks permanent stat bonuses
(speed, damage, HP, crystal/dark-matter/droid multipliers) as the
player's max zone reached increases.

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-07 10:16:46 +03:00
co-authored by Claude Sonnet 5
parent 86fd7b8e70
commit 6b638b84d0
9 changed files with 597 additions and 34 deletions
+173 -3
View File
@@ -143,10 +143,9 @@
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
} }
.music-btn { .icon-btn {
position: absolute; position: absolute;
top: 10px; top: 10px;
right: 16px;
width: 32px; width: 32px;
height: 32px; height: 32px;
border-radius: 50%; border-radius: 50%;
@@ -162,10 +161,18 @@
justify-content: center; justify-content: center;
} }
.music-btn:hover { .icon-btn:hover {
border-color: #5b8cff; border-color: #5b8cff;
} }
.music-btn {
right: 16px;
}
.pause-btn {
right: 56px;
}
/* ------------------------------------------------------------ boss banner */ /* ------------------------------------------------------------ boss banner */
.boss-banner { .boss-banner {
position: absolute; position: absolute;
@@ -483,3 +490,166 @@
transform: translateY(0) scale(1); transform: translateY(0) scale(1);
} }
} }
/* --------------------------------------------------------------- menus */
.menu-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(3, 4, 12, 0.82);
animation: lore-backdrop-in 0.2s ease-out both;
overflow-y: auto;
}
.menu-panel {
width: 100%;
max-width: 380px;
padding: 28px 26px;
border-radius: 16px;
background: linear-gradient(160deg, #12162e, #0a0c1c);
border: 1px solid rgba(91, 140, 255, 0.4);
box-shadow: 0 0 40px rgba(91, 140, 255, 0.25), 0 20px 60px rgba(0, 0, 0, 0.5);
animation: lore-panel-in 0.25s cubic-bezier(0.2, 0.8, 0.3, 1) both;
text-align: center;
}
.menu-panel--wide {
max-width: 480px;
text-align: left;
}
.menu-title {
font-size: 26px;
font-weight: 800;
color: #eef1ff;
margin-bottom: 6px;
text-align: center;
}
.menu-subtitle {
font-size: 13px;
color: #9aa1cc;
margin-bottom: 22px;
text-align: center;
}
.menu-buttons {
display: flex;
flex-direction: column;
gap: 12px;
}
.menu-btn {
padding: 12px 18px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: #151834;
color: #eef1ff;
font: inherit;
font-weight: 700;
cursor: pointer;
transition: border-color 0.15s, transform 0.1s;
}
.menu-btn:hover:not(:disabled) {
border-color: #5b8cff;
transform: translateY(-1px);
}
.menu-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.menu-btn--primary {
background: linear-gradient(135deg, #2d3d7a, #1a2350);
border-color: rgba(91, 140, 255, 0.5);
}
/* --------------------------------------------------------------- skins */
.skins-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 20px;
max-height: 50vh;
overflow-y: auto;
}
.skin-card {
display: flex;
align-items: center;
gap: 12px;
padding: 10px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: #151834;
}
.skin-card--locked {
opacity: 0.45;
}
.skin-card--equipped {
border-color: #7bffb0;
}
.skin-swatch {
position: relative;
width: 40px;
height: 40px;
flex: 0 0 auto;
border-radius: 8px;
border: 2px solid;
}
.skin-swatch-accent {
position: absolute;
bottom: 4px;
right: 4px;
width: 10px;
height: 10px;
border-radius: 50%;
}
.skin-body {
flex: 1 1 auto;
min-width: 0;
}
.skin-name {
font-size: 13px;
font-weight: 700;
color: #eef1ff;
}
.skin-bonus {
font-size: 11px;
color: #7bffb0;
}
.skin-req {
font-size: 10.5px;
color: #9aa1cc;
}
.skin-equip-btn {
flex: 0 0 auto;
padding: 6px 12px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: #1a2350;
color: #eef1ff;
font: inherit;
font-size: 11px;
font-weight: 700;
cursor: pointer;
}
.skin-equip-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
+56 -8
View File
@@ -1,32 +1,80 @@
import { useRef } from 'react'; import { useRef, useState } from 'react';
import { BossBanner } from './components/BossBanner'; import { BossBanner } from './components/BossBanner';
import { ChaseBanner } from './components/ChaseBanner'; import { ChaseBanner } from './components/ChaseBanner';
import { Hud } from './components/Hud'; import { Hud } from './components/Hud';
import { LoreModal } from './components/LoreModal'; import { LoreModal } from './components/LoreModal';
import { MainMenu } from './components/MainMenu';
import { PauseMenu } from './components/PauseMenu';
import { ShopPanel } from './components/ShopPanel'; import { ShopPanel } from './components/ShopPanel';
import { SkinsMenu } from './components/SkinsMenu';
import { SupernovaOverlay } from './components/SupernovaOverlay'; import { SupernovaOverlay } from './components/SupernovaOverlay';
import { useGameEngine } from './hooks/useGameEngine'; import { useGameEngine } from './hooks/useGameEngine';
import './App.css'; import './App.css';
type Screen = 'main' | 'skins' | 'game';
function App() { function App() {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const { engine, snapshot } = useGameEngine(canvasRef); const { engine, snapshot } = useGameEngine(canvasRef);
const [screen, setScreen] = useState<Screen>('main');
const [skinsReturnTo, setSkinsReturnTo] = useState<'main' | 'game'>('main');
const showPauseMenu = screen === 'game' && !!snapshot?.paused && !snapshot?.cutsceneActive && !snapshot?.loreActive;
return ( return (
<div className="game-root"> <div className="game-root">
<div className="game-viewport"> <div className="game-viewport">
<canvas ref={canvasRef} className="game-canvas" /> <canvas ref={canvasRef} className="game-canvas" />
{snapshot && !snapshot.cutsceneActive && ( {screen === 'game' && snapshot && !snapshot.cutsceneActive && (
<Hud snapshot={snapshot} onToggleMusic={() => engine.current?.toggleMusic()} /> <Hud
snapshot={snapshot}
onToggleMusic={() => engine.current?.toggleMusic()}
onPause={() => engine.current?.setPaused(true)}
/>
)} )}
{snapshot?.bossActive && !snapshot.cutsceneActive && <BossBanner snapshot={snapshot} />} {screen === 'game' && snapshot?.bossActive && !snapshot.cutsceneActive && <BossBanner snapshot={snapshot} />}
{snapshot?.chaseActive && !snapshot.cutsceneActive && <ChaseBanner snapshot={snapshot} />} {screen === 'game' && snapshot?.chaseActive && !snapshot.cutsceneActive && <ChaseBanner snapshot={snapshot} />}
{snapshot?.cutsceneActive && <SupernovaOverlay />} {screen === 'game' && snapshot?.cutsceneActive && <SupernovaOverlay />}
{snapshot?.loreActive && snapshot.loreEntry && ( {screen === 'game' && snapshot?.loreActive && snapshot.loreEntry && (
<LoreModal entry={snapshot.loreEntry} onDismiss={() => engine.current?.dismissLore()} /> <LoreModal entry={snapshot.loreEntry} onDismiss={() => engine.current?.dismissLore()} />
)} )}
{showPauseMenu && (
<PauseMenu
onResume={() => engine.current?.setPaused(false)}
onSkins={() => {
setSkinsReturnTo('game');
setScreen('skins');
}}
onMainMenu={() => setScreen('main')}
/>
)}
{screen === 'main' && (
<MainMenu
hasSave={!!snapshot?.hasSave}
onNewGame={() => {
engine.current?.resetToNewGame();
setScreen('game');
}}
onContinue={() => {
engine.current?.setPaused(false);
setScreen('game');
}}
onSkins={() => {
setSkinsReturnTo('main');
setScreen('skins');
}}
/>
)}
{screen === 'skins' && (
<SkinsMenu
maxZoneReached={snapshot?.maxZoneReached ?? 1}
equippedSkin={snapshot?.equippedSkin ?? 'default'}
onEquip={(id) => engine.current?.equipSkin(id)}
onBack={() => setScreen(skinsReturnTo)}
/>
)}
</div> </div>
{snapshot && ( {screen === 'game' && snapshot && (
<ShopPanel <ShopPanel
snapshot={snapshot} snapshot={snapshot}
onBuy={(key) => engine.current?.buyUpgrade(key)} onBuy={(key) => engine.current?.buyUpgrade(key)}
+13 -2
View File
@@ -1,7 +1,15 @@
import { formatNumber } from '../game/format'; import { formatNumber } from '../game/format';
import type { GameSnapshot } from '../game/types'; import type { GameSnapshot } from '../game/types';
export function Hud({ snapshot, onToggleMusic }: { snapshot: GameSnapshot; onToggleMusic: () => void }) { export function Hud({
snapshot,
onToggleMusic,
onPause,
}: {
snapshot: GameSnapshot;
onToggleMusic: () => void;
onPause: () => void;
}) {
const progressPct = Math.min(100, (snapshot.crystalsInZone / snapshot.crystalsRequired) * 100); const progressPct = Math.min(100, (snapshot.crystalsInZone / snapshot.crystalsRequired) * 100);
const hpPct = Math.min(100, (snapshot.knightHp / snapshot.knightMaxHp) * 100); const hpPct = Math.min(100, (snapshot.knightHp / snapshot.knightMaxHp) * 100);
return ( return (
@@ -45,7 +53,10 @@ export function Hud({ snapshot, onToggleMusic }: { snapshot: GameSnapshot; onTog
</div> </div>
</div> </div>
<div className="hud-hint">Тап по экрану — полёт · уворачивайтесь от астероидов</div> <div className="hud-hint">Тап по экрану — полёт · уворачивайтесь от астероидов</div>
<button type="button" className="music-btn" onClick={onToggleMusic}> <button type="button" className="icon-btn pause-btn" onClick={onPause} title="Пауза">
{'⏸️'}
</button>
<button type="button" className="icon-btn music-btn" onClick={onToggleMusic} title="Музыка">
{snapshot.musicMuted ? '\u{1F507}' : '\u{1F3B5}'} {snapshot.musicMuted ? '\u{1F507}' : '\u{1F3B5}'}
</button> </button>
</div> </div>
+31
View File
@@ -0,0 +1,31 @@
export function MainMenu({
hasSave,
onNewGame,
onContinue,
onSkins,
}: {
hasSave: boolean;
onNewGame: () => void;
onContinue: () => void;
onSkins: () => void;
}) {
return (
<div className="menu-overlay">
<div className="menu-panel">
<div className="menu-title">Космический Рыцарь</div>
<div className="menu-subtitle">Бесконечное приключение среди звёзд</div>
<div className="menu-buttons">
<button type="button" className="menu-btn menu-btn--primary" onClick={onNewGame}>
Начать новую игру
</button>
<button type="button" className="menu-btn" onClick={onContinue} disabled={!hasSave}>
Продолжить
</button>
<button type="button" className="menu-btn" onClick={onSkins}>
Скины
</button>
</div>
</div>
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
export function PauseMenu({
onResume,
onSkins,
onMainMenu,
}: {
onResume: () => void;
onSkins: () => void;
onMainMenu: () => void;
}) {
return (
<div className="menu-overlay">
<div className="menu-panel">
<div className="menu-title">Пауза</div>
<div className="menu-buttons">
<button type="button" className="menu-btn menu-btn--primary" onClick={onResume}>
Продолжить
</button>
<button type="button" className="menu-btn" onClick={onSkins}>
Скины
</button>
<button type="button" className="menu-btn" onClick={onMainMenu}>
Главное меню
</button>
</div>
</div>
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { SKINS, SKIN_BONUS_LABEL } from '../game/constants';
function bonusText(bonusType: (typeof SKINS)[number]['bonusType'], value: number): string {
const pct = Math.round(value * 100);
return `+${pct}% к параметру «${SKIN_BONUS_LABEL[bonusType]}»`;
}
export function SkinsMenu({
maxZoneReached,
equippedSkin,
onEquip,
onBack,
}: {
maxZoneReached: number;
equippedSkin: string;
onEquip: (id: string) => void;
onBack: () => void;
}) {
return (
<div className="menu-overlay">
<div className="menu-panel menu-panel--wide">
<div className="menu-title">Скины</div>
<div className="menu-subtitle">Открываются за прогресс — постоянно усиливают героя</div>
<div className="skins-list">
{SKINS.map((skin) => {
const unlocked = skin.unlockZone === 0 || maxZoneReached >= skin.unlockZone;
const equipped = equippedSkin === skin.id;
return (
<div key={skin.id} className={`skin-card${unlocked ? '' : ' skin-card--locked'}${equipped ? ' skin-card--equipped' : ''}`}>
<div className="skin-swatch" style={{ background: skin.primary, borderColor: skin.secondary }}>
<span className="skin-swatch-accent" style={{ background: skin.accent }} />
</div>
<div className="skin-body">
<div className="skin-name">{skin.name}</div>
<div className="skin-bonus">{bonusText(skin.bonusType, skin.bonusValue)}</div>
<div className="skin-req">{unlocked ? 'Открыт' : `Открывается на зоне ${skin.unlockZone}`}</div>
</div>
<button
type="button"
className="skin-equip-btn"
disabled={!unlocked || equipped}
onClick={() => onEquip(skin.id)}
>
{equipped ? 'Надет' : 'Надеть'}
</button>
</div>
);
})}
</div>
<button type="button" className="menu-btn" onClick={onBack}>
Назад
</button>
</div>
</div>
);
}
+136 -20
View File
@@ -1,4 +1,4 @@
import { WORLD, UPGRADES, DROID_TIERS, LORE_ENTRIES, type DroidTierConfig } from './constants'; import { WORLD, UPGRADES, DROID_TIERS, LORE_ENTRIES, SKINS, type DroidTierConfig } from './constants';
import { formatNumber } from './format'; import { formatNumber } from './format';
import { MusicEngine } from './music'; import { MusicEngine } from './music';
import { makeJitter, mulberry32 } from './random'; import { makeJitter, mulberry32 } from './random';
@@ -18,6 +18,7 @@ import type {
GoldenAsteroidEntity, GoldenAsteroidEntity,
LoreEntry, LoreEntry,
Particle, Particle,
SkinBonusType,
UpgradeKey, UpgradeKey,
} from './types'; } from './types';
@@ -30,6 +31,8 @@ interface SaveData {
darkMatter: number; darkMatter: number;
levels: Record<UpgradeKey, number>; levels: Record<UpgradeKey, number>;
seenLore?: number[]; seenLore?: number[];
maxZoneReached?: number;
equippedSkin?: string;
} }
const BOSS_PATTERN: BossType[] = ['melee', 'missile', 'bomb', 'laser']; const BOSS_PATTERN: BossType[] = ['melee', 'missile', 'bomb', 'laser'];
@@ -63,6 +66,7 @@ interface ChaseState {
engulfed: boolean; engulfed: boolean;
engulfTimer: number; engulfTimer: number;
escapeMeter: number; escapeMeter: number;
driftTimer: number;
} }
export class GameEngine { export class GameEngine {
@@ -85,6 +89,12 @@ export class GameEngine {
darkMatter = 0; darkMatter = 0;
levels: Record<UpgradeKey, number> = { crystal: 1, speed: 1, damage: 1, droid: 1, vitality: 1 }; levels: Record<UpgradeKey, number> = { crystal: 1, speed: 1, damage: 1, droid: 1, vitality: 1 };
private paused = true;
private maxZoneReached = 1;
private equippedSkin = 'default';
private hadSaveAtLoad = false;
private worldScrollX = 0;
private crystals: CrystalEntity[] = []; private crystals: CrystalEntity[] = [];
private pirate: EnemyEntity | null = null; private pirate: EnemyEntity | null = null;
private boss: EnemyEntity | null = null; private boss: EnemyEntity | null = null;
@@ -123,6 +133,7 @@ export class GameEngine {
engulfed: false, engulfed: false,
engulfTimer: WORLD.SWARM_ENGULF_INTERVAL, engulfTimer: WORLD.SWARM_ENGULF_INTERVAL,
escapeMeter: 0, escapeMeter: 0,
driftTimer: 0,
}; };
private chaseProjectiles: ChaseProjectile[] = []; private chaseProjectiles: ChaseProjectile[] = [];
private fuelCellEntities: FuelCellEntity[] = []; private fuelCellEntities: FuelCellEntity[] = [];
@@ -154,6 +165,7 @@ export class GameEngine {
if (!ctx) throw new Error('Canvas 2D context unavailable'); if (!ctx) throw new Error('Canvas 2D context unavailable');
this.ctx = ctx; this.ctx = ctx;
this.load(); this.load();
this.maxZoneReached = Math.max(this.maxZoneReached, this.zone);
this.resize(); this.resize();
this.knight.x = this.width / 2; this.knight.x = this.width / 2;
this.knight.y = this.height / 2; this.knight.y = this.height / 2;
@@ -226,7 +238,7 @@ export class GameEngine {
// ------------------------------------------------------------------ input // ------------------------------------------------------------------ input
private handlePointerDown(e: PointerEvent) { private handlePointerDown(e: PointerEvent) {
this.music.start(); this.music.start();
if (this.cutscene.active || this.lore.active) return; if (this.cutscene.active || this.lore.active || this.paused) return;
if (this.chase.active && this.chase.kind === 'swarm' && this.chase.engulfed) { 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); this.chase.escapeMeter = Math.min(1, this.chase.escapeMeter + WORLD.SWARM_ESCAPE_FILL_PER_TAP);
@@ -259,19 +271,26 @@ export class GameEngine {
// --------------------------------------------------------------- economy // --------------------------------------------------------------- economy
private darkMult(): number { private darkMult(): number {
return 1 + this.darkMatter; return (1 + this.darkMatter) * (1 + this.skinBonus('darkMatterMult'));
} }
private knightDamage(): number { private knightDamage(): number {
return WORLD.BASE_KNIGHT_DAMAGE * (1 + (this.levels.damage - 1) * UPGRADES.damage.bonus) * this.darkMult(); return (
WORLD.BASE_KNIGHT_DAMAGE *
(1 + (this.levels.damage - 1) * UPGRADES.damage.bonus) *
this.darkMult() *
(1 + this.skinBonus('damage'))
);
} }
private knightSpeed(): number { private knightSpeed(): number {
return WORLD.BASE_KNIGHT_SPEED * (1 + (this.levels.speed - 1) * UPGRADES.speed.bonus); return WORLD.BASE_KNIGHT_SPEED * (1 + (this.levels.speed - 1) * UPGRADES.speed.bonus) * (1 + this.skinBonus('speed'));
} }
private knightMaxHp(): number { private knightMaxHp(): number {
return WORLD.BASE_KNIGHT_MAX_HP * (1 + (this.levels.vitality - 1) * UPGRADES.vitality.bonus); return (
WORLD.BASE_KNIGHT_MAX_HP * (1 + (this.levels.vitality - 1) * UPGRADES.vitality.bonus) * (1 + this.skinBonus('hp'))
);
} }
private crystalValue(): number { private crystalValue(): number {
@@ -279,7 +298,8 @@ export class GameEngine {
WORLD.BASE_CRYSTAL_VALUE * WORLD.BASE_CRYSTAL_VALUE *
Math.pow(WORLD.CRYSTAL_VALUE_ZONE_MULT, this.zone - 1) * Math.pow(WORLD.CRYSTAL_VALUE_ZONE_MULT, this.zone - 1) *
(1 + (this.levels.crystal - 1) * UPGRADES.crystal.bonus) * (1 + (this.levels.crystal - 1) * UPGRADES.crystal.bonus) *
this.darkMult() this.darkMult() *
(1 + this.skinBonus('crystalMult'))
); );
} }
@@ -331,8 +351,38 @@ export class GameEngine {
return muted; return muted;
} }
private applyPrestigeReset() { setPaused(paused: boolean): void {
this.darkMatter += WORLD.SUPERNOVA_DARK_MATTER_GAIN; if (this.cutscene.active || this.lore.active) return;
this.paused = paused;
this.notify();
}
togglePause(): boolean {
this.setPaused(!this.paused);
return this.paused;
}
equipSkin(id: string): boolean {
const skin = SKINS.find((s) => s.id === id);
if (!skin) return false;
if (skin.unlockZone > 0 && this.maxZoneReached < skin.unlockZone) return false;
this.equippedSkin = id;
this.save();
this.notify();
return true;
}
private skinBonus(type: SkinBonusType): number {
let total = 0;
for (const s of SKINS) {
if (s.bonusType === type && (s.unlockZone === 0 || this.maxZoneReached >= s.unlockZone)) {
total += s.bonusValue;
}
}
return total;
}
private resetRun() {
this.score = 0; this.score = 0;
this.zone = 1; this.zone = 1;
this.crystalsInZone = 0; this.crystalsInZone = 0;
@@ -352,6 +402,7 @@ export class GameEngine {
engulfed: false, engulfed: false,
engulfTimer: WORLD.SWARM_ENGULF_INTERVAL, engulfTimer: WORLD.SWARM_ENGULF_INTERVAL,
escapeMeter: 0, escapeMeter: 0,
driftTimer: 0,
}; };
this.chaseProjectiles = []; this.chaseProjectiles = [];
this.fuelCellEntities = []; this.fuelCellEntities = [];
@@ -371,9 +422,21 @@ export class GameEngine {
this.knight.invulnTimer = 0; this.knight.invulnTimer = 0;
this.moveTarget = null; this.moveTarget = null;
this.rebuildDroids(); this.rebuildDroids();
}
private applyPrestigeReset() {
this.darkMatter += WORLD.SUPERNOVA_DARK_MATTER_GAIN;
this.resetRun();
this.save(); this.save();
} }
resetToNewGame(): void {
this.resetRun();
this.paused = false;
this.save();
this.notify();
}
private rebuildDroids() { private rebuildDroids() {
const count = Math.max(0, this.levels.droid - 1); const count = Math.max(0, this.levels.droid - 1);
this.droids = Array.from({ length: count }, (_, i) => ({ this.droids = Array.from({ length: count }, (_, i) => ({
@@ -461,10 +524,12 @@ export class GameEngine {
engulfed: false, engulfed: false,
engulfTimer: WORLD.SWARM_ENGULF_INTERVAL, engulfTimer: WORLD.SWARM_ENGULF_INTERVAL,
escapeMeter: 0, escapeMeter: 0,
driftTimer: WORLD.CHASE_DRIFT_DURATION,
}; };
this.chaseProjectiles = []; this.chaseProjectiles = [];
this.fuelCellEntities = []; this.fuelCellEntities = [];
this.fuelCellSpawnTimer = 0; this.fuelCellSpawnTimer = 0;
this.worldScrollX = 0;
this.crystals = []; this.crystals = [];
this.pirate = null; this.pirate = null;
this.boss = null; this.boss = null;
@@ -484,9 +549,11 @@ export class GameEngine {
this.chase.engulfed = false; this.chase.engulfed = false;
this.chase.engulfTimer = WORLD.SWARM_ENGULF_INTERVAL; this.chase.engulfTimer = WORLD.SWARM_ENGULF_INTERVAL;
this.chase.escapeMeter = 0; this.chase.escapeMeter = 0;
this.chase.driftTimer = WORLD.CHASE_DRIFT_DURATION;
this.chaseProjectiles = []; this.chaseProjectiles = [];
this.fuelCellEntities = []; this.fuelCellEntities = [];
this.fuelCellSpawnTimer = 0; this.fuelCellSpawnTimer = 0;
this.worldScrollX = 0;
this.knight.hp = this.knightMaxHp(); this.knight.hp = this.knightMaxHp();
this.knight.invulnTimer = WORLD.HIT_INVULN_DURATION; this.knight.invulnTimer = WORLD.HIT_INVULN_DURATION;
this.knight.x = this.width / 2; this.knight.x = this.width / 2;
@@ -680,8 +747,8 @@ export class GameEngine {
if (this.cutscene.active) { if (this.cutscene.active) {
this.updateCutscene(dt); this.updateCutscene(dt);
} else if (this.lore.active) { } else if (this.lore.active || this.paused) {
// frozen: the story archive modal pauses the world until dismissed // frozen: the story archive modal / pause menu holds the world until dismissed
} else { } else {
this.updateAsteroids(dt); this.updateAsteroids(dt);
if (this.chase.active) this.updateChase(dt); if (this.chase.active) this.updateChase(dt);
@@ -692,7 +759,7 @@ 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) { if (!this.lore.active && !this.paused) {
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);
@@ -818,6 +885,7 @@ export class GameEngine {
private finishZone(completedZone: number) { private finishZone(completedZone: number) {
this.zone = completedZone + 1; this.zone = completedZone + 1;
if (this.zone > this.maxZoneReached) this.maxZoneReached = this.zone;
this.crystalsInZone = 0; this.crystalsInZone = 0;
this.crystals = []; this.crystals = [];
this.pirate = null; this.pirate = null;
@@ -1012,6 +1080,7 @@ export class GameEngine {
const engulfedFreeze = this.chase.kind === 'swarm' && this.chase.engulfed; const engulfedFreeze = this.chase.kind === 'swarm' && this.chase.engulfed;
if (!engulfedFreeze) { if (!engulfedFreeze) {
this.applyMoveTarget(dt); this.applyMoveTarget(dt);
this.applyChaseDrift(dt);
} else { } else {
this.knight.thrusting = false; this.knight.thrusting = false;
} }
@@ -1023,6 +1092,21 @@ export class GameEngine {
else if (this.chase.kind === 'swarm') this.updateSwarmChase(dt); else if (this.chase.kind === 'swarm') this.updateSwarmChase(dt);
} }
private applyChaseDrift(dt: number) {
if (this.chase.driftTimer <= 0) return;
this.chase.driftTimer = Math.max(0, this.chase.driftTimer - dt);
const dx = WORLD.CHASE_DRIFT_SPEED * dt;
this.knight.x += dx;
this.worldScrollX += dx;
for (const c of this.crystals) c.x += dx;
for (const a of this.asteroids) a.x += dx;
if (this.goldenAsteroid) this.goldenAsteroid.x += dx;
for (const f of this.fuelCellEntities) f.x += dx;
for (const p of this.chaseProjectiles) p.x += dx;
for (const p of this.particles) p.x += dx;
for (const f of this.floatingTexts) f.x += dx;
}
private updateCruiserChase(dt: number) { private updateCruiserChase(dt: number) {
if (this.fuelCellEntities.length < WORLD.FUEL_CELL_MAX_ON_SCREEN) { if (this.fuelCellEntities.length < WORLD.FUEL_CELL_MAX_ON_SCREEN) {
this.fuelCellSpawnTimer += dt; this.fuelCellSpawnTimer += dt;
@@ -1142,7 +1226,7 @@ export class GameEngine {
d.cooldown += dt; d.cooldown += dt;
if (d.cooldown >= WORLD.DROID_INTERVAL) { if (d.cooldown >= WORLD.DROID_INTERVAL) {
d.cooldown = 0; d.cooldown = 0;
this.dealDamageTo(target, this.knightDamage() * WORLD.DROID_DAMAGE_RATIO); this.dealDamageTo(target, this.knightDamage() * WORLD.DROID_DAMAGE_RATIO * (1 + this.skinBonus('droidDamageMult')));
d.laserTimer = 1; d.laserTimer = 1;
} }
} }
@@ -1191,7 +1275,7 @@ export class GameEngine {
ctx.closePath(); ctx.closePath();
} }
private drawBackground() { private drawBackground(scrollX = 0) {
const ctx = this.ctx; const ctx = this.ctx;
const g = ctx.createLinearGradient(0, 0, 0, this.height); const g = ctx.createLinearGradient(0, 0, 0, this.height);
g.addColorStop(0, '#0a0e24'); g.addColorStop(0, '#0a0e24');
@@ -1202,13 +1286,31 @@ export class GameEngine {
ctx.fillStyle = '#ffffff'; ctx.fillStyle = '#ffffff';
for (const s of this.stars) { for (const s of this.stars) {
ctx.globalAlpha = s.a; ctx.globalAlpha = s.a;
const x = (((s.x * this.width + scrollX) % this.width) + this.width) % this.width;
ctx.beginPath(); ctx.beginPath();
ctx.arc(s.x * this.width, s.y * this.height, s.r, 0, Math.PI * 2); ctx.arc(x, s.y * this.height, s.r, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
} }
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
} }
private drawDriftIndicator(remaining: number) {
const ctx = this.ctx;
const alpha = 0.55 + Math.sin(this.time * 6) * 0.25;
ctx.save();
ctx.textAlign = 'center';
ctx.fillStyle = `rgba(255, 214, 102, ${alpha})`;
ctx.font = 'bold 15px system-ui, sans-serif';
ctx.shadowColor = '#ffb43c';
ctx.shadowBlur = 10;
ctx.fillText('← ТЕЧЕНИЕ! ДВИГАЙТЕСЬ ВЛЕВО', this.width / 2, 228);
ctx.shadowBlur = 0;
ctx.font = '12px system-ui, sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.fillText(`${Math.ceil(remaining)}с`, this.width / 2, 246);
ctx.restore();
}
private drawCrystal(c: CrystalEntity) { private drawCrystal(c: CrystalEntity) {
const ctx = this.ctx; const ctx = this.ctx;
ctx.save(); ctx.save();
@@ -1686,6 +1788,7 @@ export class GameEngine {
private drawKnight() { private drawKnight() {
const ctx = this.ctx; const ctx = this.ctx;
const k = this.knight; const k = this.knight;
const skin = SKINS.find((s) => s.id === this.equippedSkin) ?? SKINS[0];
ctx.save(); ctx.save();
ctx.translate(k.x, k.y); ctx.translate(k.x, k.y);
ctx.rotate(k.facing + Math.PI / 2); ctx.rotate(k.facing + Math.PI / 2);
@@ -1706,21 +1809,21 @@ export class GameEngine {
ctx.lineTo(0, 18); ctx.lineTo(0, 18);
ctx.lineTo(-12, 10); ctx.lineTo(-12, 10);
ctx.closePath(); ctx.closePath();
ctx.fillStyle = '#d7dbe6'; ctx.fillStyle = skin.primary;
ctx.fill(); ctx.fill();
ctx.strokeStyle = '#8890a6'; ctx.strokeStyle = skin.secondary;
ctx.lineWidth = 1.5; ctx.lineWidth = 1.5;
ctx.stroke(); ctx.stroke();
ctx.beginPath(); ctx.beginPath();
ctx.arc(0, 2, 3, 0, Math.PI * 2); ctx.arc(0, 2, 3, 0, Math.PI * 2);
ctx.fillStyle = k.hitFlash > 0 ? '#ffffff' : '#5bd0ff'; ctx.fillStyle = k.hitFlash > 0 ? '#ffffff' : skin.accent;
ctx.fill(); ctx.fill();
this.polygonPath(0, -18, 10, 7, 0); this.polygonPath(0, -18, 10, 7, 0);
ctx.fillStyle = '#eef2ff'; ctx.fillStyle = '#eef2ff';
ctx.fill(); ctx.fill();
ctx.strokeStyle = '#8890a6'; ctx.strokeStyle = skin.secondary;
ctx.stroke(); ctx.stroke();
ctx.beginPath(); ctx.beginPath();
ctx.ellipse(0, -18, 5, 4, 0, 0, Math.PI * 2); ctx.ellipse(0, -18, 5, 4, 0, 0, Math.PI * 2);
@@ -1881,7 +1984,8 @@ export class GameEngine {
this.renderCutscene(); this.renderCutscene();
return; return;
} }
this.drawBackground(); const driftActive = this.chase.active && this.chase.driftTimer > 0;
this.drawBackground(driftActive ? this.worldScrollX : 0);
if (this.chase.active) this.drawChaseBackdrop(); if (this.chase.active) this.drawChaseBackdrop();
for (const c of this.crystals) this.drawCrystal(c); for (const c of this.crystals) this.drawCrystal(c);
if (this.chase.active && this.chase.kind === 'cruiser') { if (this.chase.active && this.chase.kind === 'cruiser') {
@@ -1902,6 +2006,7 @@ export class GameEngine {
this.drawKnight(); this.drawKnight();
this.drawKnightHealthBar(); this.drawKnightHealthBar();
this.drawFloatingTexts(); this.drawFloatingTexts();
if (driftActive) this.drawDriftIndicator(this.chase.driftTimer);
if (this.chase.active && this.chase.kind === 'swarm' && this.chase.engulfed) { if (this.chase.active && this.chase.kind === 'swarm' && this.chase.engulfed) {
this.drawEngulfOverlay(); this.drawEngulfOverlay();
} }
@@ -1964,6 +2069,12 @@ export class GameEngine {
chaseTimeRemaining: this.chase.kind === 'swarm' ? Math.max(0, WORLD.SWARM_DURATION - this.chase.timer) : 0, chaseTimeRemaining: this.chase.kind === 'swarm' ? Math.max(0, WORLD.SWARM_DURATION - this.chase.timer) : 0,
chaseEngulfed: this.chase.engulfed, chaseEngulfed: this.chase.engulfed,
chaseEscapeMeter: this.chase.escapeMeter, chaseEscapeMeter: this.chase.escapeMeter,
chaseDriftActive: this.chase.active && this.chase.driftTimer > 0,
chaseDriftRemaining: this.chase.driftTimer,
paused: this.paused,
maxZoneReached: this.maxZoneReached,
equippedSkin: this.equippedSkin,
hasSave: this.hadSaveAtLoad,
}; };
} }
@@ -1977,6 +2088,8 @@ export class GameEngine {
darkMatter: this.darkMatter, darkMatter: this.darkMatter,
levels: { ...this.levels }, levels: { ...this.levels },
seenLore: Array.from(this.seenLore), seenLore: Array.from(this.seenLore),
maxZoneReached: this.maxZoneReached,
equippedSkin: this.equippedSkin,
}; };
localStorage.setItem(SAVE_KEY, JSON.stringify(data)); localStorage.setItem(SAVE_KEY, JSON.stringify(data));
} catch { } catch {
@@ -1988,6 +2101,7 @@ export class GameEngine {
try { try {
const raw = localStorage.getItem(SAVE_KEY); const raw = localStorage.getItem(SAVE_KEY);
if (!raw) return; if (!raw) return;
this.hadSaveAtLoad = true;
const data = JSON.parse(raw) as Partial<SaveData>; const data = JSON.parse(raw) as Partial<SaveData>;
this.score = data.score ?? 0; this.score = data.score ?? 0;
this.zone = data.zone ?? 1; this.zone = data.zone ?? 1;
@@ -2001,6 +2115,8 @@ export class GameEngine {
vitality: data.levels?.vitality ?? 1, vitality: data.levels?.vitality ?? 1,
}; };
this.seenLore = new Set(data.seenLore ?? []); this.seenLore = new Set(data.seenLore ?? []);
this.maxZoneReached = data.maxZoneReached ?? this.zone;
this.equippedSkin = data.equippedSkin ?? 'default';
} catch { } catch {
// corrupted save, start fresh // corrupted save, start fresh
} }
+85 -1
View File
@@ -1,4 +1,4 @@
import type { LoreEntry, UpgradeKey } from './types'; import type { LoreEntry, SkinDef, UpgradeKey } from './types';
export const WORLD = { export const WORLD = {
CRYSTALS_PER_ZONE: 10, CRYSTALS_PER_ZONE: 10,
@@ -67,6 +67,8 @@ export const WORLD = {
SWARM_ENGULF_INTERVAL: 10, SWARM_ENGULF_INTERVAL: 10,
SWARM_ESCAPE_FILL_PER_TAP: 0.22, SWARM_ESCAPE_FILL_PER_TAP: 0.22,
SWARM_ESCAPE_DECAY_PER_SEC: 0.12, SWARM_ESCAPE_DECAY_PER_SEC: 0.12,
CHASE_DRIFT_DURATION: 20,
CHASE_DRIFT_SPEED: 120,
} as const; } as const;
export const UPGRADES: Record<UpgradeKey, { title: string; desc: string; icon: string; baseCost: number; growth: number; bonus: number }> = { export const UPGRADES: Record<UpgradeKey, { title: string; desc: string; icon: string; baseCost: number; growth: number; bonus: number }> = {
@@ -161,3 +163,85 @@ export const LORE_ENTRIES: LoreEntry[] = [
text: 'Архив Данных: Звёздный Реактор активирован на 100%! Вы открыли врата к Сердцу Галактики. Впереди виден Древний Город, но он полностью окружен армадой ИИ "Ноль". Нажмите "Взрыв Сверхновой", чтобы совершить решающий прыжок с силой Тёмной Материи!', text: 'Архив Данных: Звёздный Реактор активирован на 100%! Вы открыли врата к Сердцу Галактики. Впереди виден Древний Город, но он полностью окружен армадой ИИ "Ноль". Нажмите "Взрыв Сверхновой", чтобы совершить решающий прыжок с силой Тёмной Материи!',
}, },
]; ];
export const SKINS: SkinDef[] = [
{
id: 'default',
name: 'Стандартный Скафандр',
unlockZone: 0,
bonusType: 'speed',
bonusValue: 0,
primary: '#d7dbe6',
secondary: '#8890a6',
accent: '#5bd0ff',
},
{
id: 'swift',
name: 'Скоростной Скафандр',
unlockZone: 5,
bonusType: 'speed',
bonusValue: 0.1,
primary: '#bfe9ff',
secondary: '#3c8cff',
accent: '#8ef7ff',
},
{
id: 'warrior',
name: 'Боевой Скафандр',
unlockZone: 10,
bonusType: 'damage',
bonusValue: 0.1,
primary: '#ffd7d7',
secondary: '#c0203f',
accent: '#ff5a3c',
},
{
id: 'guardian',
name: 'Скафандр Стража',
unlockZone: 25,
bonusType: 'hp',
bonusValue: 0.1,
primary: '#d7ffe0',
secondary: '#0f7a4a',
accent: '#7bffb0',
},
{
id: 'miner',
name: 'Скафандр Старателя',
unlockZone: 50,
bonusType: 'crystalMult',
bonusValue: 0.15,
primary: '#fff0c8',
secondary: '#c8951c',
accent: '#ffd35b',
},
{
id: 'voidwalker',
name: 'Скафандр Ходока Пустоты',
unlockZone: 75,
bonusType: 'darkMatterMult',
bonusValue: 0.15,
primary: '#e6d7ff',
secondary: '#6a1b9a',
accent: '#c084fc',
},
{
id: 'commander',
name: 'Скафандр Командира',
unlockZone: 100,
bonusType: 'droidDamageMult',
bonusValue: 0.2,
primary: '#ffffff',
secondary: '#ff3cf0',
accent: '#ffd35b',
},
];
export const SKIN_BONUS_LABEL: Record<SkinDef['bonusType'], string> = {
speed: 'скорость',
damage: 'урон',
hp: 'здоровье',
crystalMult: 'заработок за кристаллы',
darkMatterMult: 'силу Тёмной Материи',
droidDamageMult: 'урон дроида',
};
+19
View File
@@ -124,6 +124,19 @@ export interface LoreEntry {
text: string; text: string;
} }
export type SkinBonusType = 'speed' | 'damage' | 'hp' | 'crystalMult' | 'darkMatterMult' | 'droidDamageMult';
export interface SkinDef {
id: string;
name: string;
unlockZone: number;
bonusType: SkinBonusType;
bonusValue: number;
primary: string;
secondary: string;
accent: string;
}
export interface GameSnapshot { export interface GameSnapshot {
score: number; score: number;
zone: number; zone: number;
@@ -155,4 +168,10 @@ export interface GameSnapshot {
chaseTimeRemaining: number; chaseTimeRemaining: number;
chaseEngulfed: boolean; chaseEngulfed: boolean;
chaseEscapeMeter: number; chaseEscapeMeter: number;
chaseDriftActive: boolean;
chaseDriftRemaining: number;
paused: boolean;
maxZoneReached: number;
equippedSkin: string;
hasSave: boolean;
} }