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);
}
.music-btn {
.icon-btn {
position: absolute;
top: 10px;
right: 16px;
width: 32px;
height: 32px;
border-radius: 50%;
@@ -162,10 +161,18 @@
justify-content: center;
}
.music-btn:hover {
.icon-btn:hover {
border-color: #5b8cff;
}
.music-btn {
right: 16px;
}
.pause-btn {
right: 56px;
}
/* ------------------------------------------------------------ boss banner */
.boss-banner {
position: absolute;
@@ -483,3 +490,166 @@
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 { ChaseBanner } from './components/ChaseBanner';
import { Hud } from './components/Hud';
import { LoreModal } from './components/LoreModal';
import { MainMenu } from './components/MainMenu';
import { PauseMenu } from './components/PauseMenu';
import { ShopPanel } from './components/ShopPanel';
import { SkinsMenu } from './components/SkinsMenu';
import { SupernovaOverlay } from './components/SupernovaOverlay';
import { useGameEngine } from './hooks/useGameEngine';
import './App.css';
type Screen = 'main' | 'skins' | 'game';
function App() {
const canvasRef = useRef<HTMLCanvasElement>(null);
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 (
<div className="game-root">
<div className="game-viewport">
<canvas ref={canvasRef} className="game-canvas" />
{snapshot && !snapshot.cutsceneActive && (
<Hud snapshot={snapshot} onToggleMusic={() => engine.current?.toggleMusic()} />
{screen === 'game' && snapshot && !snapshot.cutsceneActive && (
<Hud
snapshot={snapshot}
onToggleMusic={() => engine.current?.toggleMusic()}
onPause={() => engine.current?.setPaused(true)}
/>
)}
{snapshot?.bossActive && !snapshot.cutsceneActive && <BossBanner snapshot={snapshot} />}
{snapshot?.chaseActive && !snapshot.cutsceneActive && <ChaseBanner snapshot={snapshot} />}
{snapshot?.cutsceneActive && <SupernovaOverlay />}
{snapshot?.loreActive && snapshot.loreEntry && (
{screen === 'game' && snapshot?.bossActive && !snapshot.cutsceneActive && <BossBanner snapshot={snapshot} />}
{screen === 'game' && snapshot?.chaseActive && !snapshot.cutsceneActive && <ChaseBanner snapshot={snapshot} />}
{screen === 'game' && snapshot?.cutsceneActive && <SupernovaOverlay />}
{screen === 'game' && snapshot?.loreActive && snapshot.loreEntry && (
<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>
{snapshot && (
{screen === 'game' && snapshot && (
<ShopPanel
snapshot={snapshot}
onBuy={(key) => engine.current?.buyUpgrade(key)}
+13 -2
View File
@@ -1,7 +1,15 @@
import { formatNumber } from '../game/format';
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 hpPct = Math.min(100, (snapshot.knightHp / snapshot.knightMaxHp) * 100);
return (
@@ -45,7 +53,10 @@ export function Hud({ snapshot, onToggleMusic }: { snapshot: GameSnapshot; onTog
</div>
</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}'}
</button>
</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 { MusicEngine } from './music';
import { makeJitter, mulberry32 } from './random';
@@ -18,6 +18,7 @@ import type {
GoldenAsteroidEntity,
LoreEntry,
Particle,
SkinBonusType,
UpgradeKey,
} from './types';
@@ -30,6 +31,8 @@ interface SaveData {
darkMatter: number;
levels: Record<UpgradeKey, number>;
seenLore?: number[];
maxZoneReached?: number;
equippedSkin?: string;
}
const BOSS_PATTERN: BossType[] = ['melee', 'missile', 'bomb', 'laser'];
@@ -63,6 +66,7 @@ interface ChaseState {
engulfed: boolean;
engulfTimer: number;
escapeMeter: number;
driftTimer: number;
}
export class GameEngine {
@@ -85,6 +89,12 @@ export class GameEngine {
darkMatter = 0;
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 pirate: EnemyEntity | null = null;
private boss: EnemyEntity | null = null;
@@ -123,6 +133,7 @@ export class GameEngine {
engulfed: false,
engulfTimer: WORLD.SWARM_ENGULF_INTERVAL,
escapeMeter: 0,
driftTimer: 0,
};
private chaseProjectiles: ChaseProjectile[] = [];
private fuelCellEntities: FuelCellEntity[] = [];
@@ -154,6 +165,7 @@ export class GameEngine {
if (!ctx) throw new Error('Canvas 2D context unavailable');
this.ctx = ctx;
this.load();
this.maxZoneReached = Math.max(this.maxZoneReached, this.zone);
this.resize();
this.knight.x = this.width / 2;
this.knight.y = this.height / 2;
@@ -226,7 +238,7 @@ export class GameEngine {
// ------------------------------------------------------------------ input
private handlePointerDown(e: PointerEvent) {
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) {
this.chase.escapeMeter = Math.min(1, this.chase.escapeMeter + WORLD.SWARM_ESCAPE_FILL_PER_TAP);
@@ -259,19 +271,26 @@ export class GameEngine {
// --------------------------------------------------------------- economy
private darkMult(): number {
return 1 + this.darkMatter;
return (1 + this.darkMatter) * (1 + this.skinBonus('darkMatterMult'));
}
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 {
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 {
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 {
@@ -279,7 +298,8 @@ export class GameEngine {
WORLD.BASE_CRYSTAL_VALUE *
Math.pow(WORLD.CRYSTAL_VALUE_ZONE_MULT, this.zone - 1) *
(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;
}
private applyPrestigeReset() {
this.darkMatter += WORLD.SUPERNOVA_DARK_MATTER_GAIN;
setPaused(paused: boolean): void {
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.zone = 1;
this.crystalsInZone = 0;
@@ -352,6 +402,7 @@ export class GameEngine {
engulfed: false,
engulfTimer: WORLD.SWARM_ENGULF_INTERVAL,
escapeMeter: 0,
driftTimer: 0,
};
this.chaseProjectiles = [];
this.fuelCellEntities = [];
@@ -371,9 +422,21 @@ export class GameEngine {
this.knight.invulnTimer = 0;
this.moveTarget = null;
this.rebuildDroids();
}
private applyPrestigeReset() {
this.darkMatter += WORLD.SUPERNOVA_DARK_MATTER_GAIN;
this.resetRun();
this.save();
}
resetToNewGame(): void {
this.resetRun();
this.paused = false;
this.save();
this.notify();
}
private rebuildDroids() {
const count = Math.max(0, this.levels.droid - 1);
this.droids = Array.from({ length: count }, (_, i) => ({
@@ -461,10 +524,12 @@ export class GameEngine {
engulfed: false,
engulfTimer: WORLD.SWARM_ENGULF_INTERVAL,
escapeMeter: 0,
driftTimer: WORLD.CHASE_DRIFT_DURATION,
};
this.chaseProjectiles = [];
this.fuelCellEntities = [];
this.fuelCellSpawnTimer = 0;
this.worldScrollX = 0;
this.crystals = [];
this.pirate = null;
this.boss = null;
@@ -484,9 +549,11 @@ export class GameEngine {
this.chase.engulfed = false;
this.chase.engulfTimer = WORLD.SWARM_ENGULF_INTERVAL;
this.chase.escapeMeter = 0;
this.chase.driftTimer = WORLD.CHASE_DRIFT_DURATION;
this.chaseProjectiles = [];
this.fuelCellEntities = [];
this.fuelCellSpawnTimer = 0;
this.worldScrollX = 0;
this.knight.hp = this.knightMaxHp();
this.knight.invulnTimer = WORLD.HIT_INVULN_DURATION;
this.knight.x = this.width / 2;
@@ -680,8 +747,8 @@ export class GameEngine {
if (this.cutscene.active) {
this.updateCutscene(dt);
} else if (this.lore.active) {
// frozen: the story archive modal pauses the world until dismissed
} else if (this.lore.active || this.paused) {
// frozen: the story archive modal / pause menu holds the world until dismissed
} else {
this.updateAsteroids(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;
}
if (!this.lore.active) {
if (!this.lore.active && !this.paused) {
this.updateParticles(dt);
if (this.pirate && this.pirate.hitFlash > 0) this.pirate.hitFlash = Math.max(0, this.pirate.hitFlash - dt * 3);
if (this.boss && this.boss.hitFlash > 0) this.boss.hitFlash = Math.max(0, this.boss.hitFlash - dt * 3);
@@ -818,6 +885,7 @@ export class GameEngine {
private finishZone(completedZone: number) {
this.zone = completedZone + 1;
if (this.zone > this.maxZoneReached) this.maxZoneReached = this.zone;
this.crystalsInZone = 0;
this.crystals = [];
this.pirate = null;
@@ -1012,6 +1080,7 @@ export class GameEngine {
const engulfedFreeze = this.chase.kind === 'swarm' && this.chase.engulfed;
if (!engulfedFreeze) {
this.applyMoveTarget(dt);
this.applyChaseDrift(dt);
} else {
this.knight.thrusting = false;
}
@@ -1023,6 +1092,21 @@ export class GameEngine {
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) {
if (this.fuelCellEntities.length < WORLD.FUEL_CELL_MAX_ON_SCREEN) {
this.fuelCellSpawnTimer += dt;
@@ -1142,7 +1226,7 @@ export class GameEngine {
d.cooldown += dt;
if (d.cooldown >= WORLD.DROID_INTERVAL) {
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;
}
}
@@ -1191,7 +1275,7 @@ export class GameEngine {
ctx.closePath();
}
private drawBackground() {
private drawBackground(scrollX = 0) {
const ctx = this.ctx;
const g = ctx.createLinearGradient(0, 0, 0, this.height);
g.addColorStop(0, '#0a0e24');
@@ -1202,13 +1286,31 @@ export class GameEngine {
ctx.fillStyle = '#ffffff';
for (const s of this.stars) {
ctx.globalAlpha = s.a;
const x = (((s.x * this.width + scrollX) % this.width) + this.width) % this.width;
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.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) {
const ctx = this.ctx;
ctx.save();
@@ -1686,6 +1788,7 @@ export class GameEngine {
private drawKnight() {
const ctx = this.ctx;
const k = this.knight;
const skin = SKINS.find((s) => s.id === this.equippedSkin) ?? SKINS[0];
ctx.save();
ctx.translate(k.x, k.y);
ctx.rotate(k.facing + Math.PI / 2);
@@ -1706,21 +1809,21 @@ export class GameEngine {
ctx.lineTo(0, 18);
ctx.lineTo(-12, 10);
ctx.closePath();
ctx.fillStyle = '#d7dbe6';
ctx.fillStyle = skin.primary;
ctx.fill();
ctx.strokeStyle = '#8890a6';
ctx.strokeStyle = skin.secondary;
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.beginPath();
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();
this.polygonPath(0, -18, 10, 7, 0);
ctx.fillStyle = '#eef2ff';
ctx.fill();
ctx.strokeStyle = '#8890a6';
ctx.strokeStyle = skin.secondary;
ctx.stroke();
ctx.beginPath();
ctx.ellipse(0, -18, 5, 4, 0, 0, Math.PI * 2);
@@ -1881,7 +1984,8 @@ export class GameEngine {
this.renderCutscene();
return;
}
this.drawBackground();
const driftActive = this.chase.active && this.chase.driftTimer > 0;
this.drawBackground(driftActive ? this.worldScrollX : 0);
if (this.chase.active) this.drawChaseBackdrop();
for (const c of this.crystals) this.drawCrystal(c);
if (this.chase.active && this.chase.kind === 'cruiser') {
@@ -1902,6 +2006,7 @@ export class GameEngine {
this.drawKnight();
this.drawKnightHealthBar();
this.drawFloatingTexts();
if (driftActive) this.drawDriftIndicator(this.chase.driftTimer);
if (this.chase.active && this.chase.kind === 'swarm' && this.chase.engulfed) {
this.drawEngulfOverlay();
}
@@ -1964,6 +2069,12 @@ export class GameEngine {
chaseTimeRemaining: this.chase.kind === 'swarm' ? Math.max(0, WORLD.SWARM_DURATION - this.chase.timer) : 0,
chaseEngulfed: this.chase.engulfed,
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,
levels: { ...this.levels },
seenLore: Array.from(this.seenLore),
maxZoneReached: this.maxZoneReached,
equippedSkin: this.equippedSkin,
};
localStorage.setItem(SAVE_KEY, JSON.stringify(data));
} catch {
@@ -1988,6 +2101,7 @@ export class GameEngine {
try {
const raw = localStorage.getItem(SAVE_KEY);
if (!raw) return;
this.hadSaveAtLoad = true;
const data = JSON.parse(raw) as Partial<SaveData>;
this.score = data.score ?? 0;
this.zone = data.zone ?? 1;
@@ -2001,6 +2115,8 @@ export class GameEngine {
vitality: data.levels?.vitality ?? 1,
};
this.seenLore = new Set(data.seenLore ?? []);
this.maxZoneReached = data.maxZoneReached ?? this.zone;
this.equippedSkin = data.equippedSkin ?? 'default';
} catch {
// 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 = {
CRYSTALS_PER_ZONE: 10,
@@ -67,6 +67,8 @@ export const WORLD = {
SWARM_ENGULF_INTERVAL: 10,
SWARM_ESCAPE_FILL_PER_TAP: 0.22,
SWARM_ESCAPE_DECAY_PER_SEC: 0.12,
CHASE_DRIFT_DURATION: 20,
CHASE_DRIFT_SPEED: 120,
} as const;
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%! Вы открыли врата к Сердцу Галактики. Впереди виден Древний Город, но он полностью окружен армадой ИИ "Ноль". Нажмите "Взрыв Сверхновой", чтобы совершить решающий прыжок с силой Тёмной Материи!',
},
];
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;
}
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 {
score: number;
zone: number;
@@ -155,4 +168,10 @@ export interface GameSnapshot {
chaseTimeRemaining: number;
chaseEngulfed: boolean;
chaseEscapeMeter: number;
chaseDriftActive: boolean;
chaseDriftRemaining: number;
paused: boolean;
maxZoneReached: number;
equippedSkin: string;
hasSave: boolean;
}