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:
co-authored by
Claude Sonnet 5
parent
86fd7b8e70
commit
6b638b84d0
+173
-3
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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: 'урон дроида',
|
||||||
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user