diff --git a/src/App.css b/src/App.css index ee51856..d0768be 100644 --- a/src/App.css +++ b/src/App.css @@ -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; +} diff --git a/src/App.tsx b/src/App.tsx index 4f21327..429299e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(null); const { engine, snapshot } = useGameEngine(canvasRef); + const [screen, setScreen] = useState('main'); + const [skinsReturnTo, setSkinsReturnTo] = useState<'main' | 'game'>('main'); + + const showPauseMenu = screen === 'game' && !!snapshot?.paused && !snapshot?.cutsceneActive && !snapshot?.loreActive; return (
- {snapshot && !snapshot.cutsceneActive && ( - engine.current?.toggleMusic()} /> + {screen === 'game' && snapshot && !snapshot.cutsceneActive && ( + engine.current?.toggleMusic()} + onPause={() => engine.current?.setPaused(true)} + /> )} - {snapshot?.bossActive && !snapshot.cutsceneActive && } - {snapshot?.chaseActive && !snapshot.cutsceneActive && } - {snapshot?.cutsceneActive && } - {snapshot?.loreActive && snapshot.loreEntry && ( + {screen === 'game' && snapshot?.bossActive && !snapshot.cutsceneActive && } + {screen === 'game' && snapshot?.chaseActive && !snapshot.cutsceneActive && } + {screen === 'game' && snapshot?.cutsceneActive && } + {screen === 'game' && snapshot?.loreActive && snapshot.loreEntry && ( engine.current?.dismissLore()} /> )} + {showPauseMenu && ( + engine.current?.setPaused(false)} + onSkins={() => { + setSkinsReturnTo('game'); + setScreen('skins'); + }} + onMainMenu={() => setScreen('main')} + /> + )} + {screen === 'main' && ( + { + engine.current?.resetToNewGame(); + setScreen('game'); + }} + onContinue={() => { + engine.current?.setPaused(false); + setScreen('game'); + }} + onSkins={() => { + setSkinsReturnTo('main'); + setScreen('skins'); + }} + /> + )} + {screen === 'skins' && ( + engine.current?.equipSkin(id)} + onBack={() => setScreen(skinsReturnTo)} + /> + )}
- {snapshot && ( + {screen === 'game' && snapshot && ( engine.current?.buyUpgrade(key)} diff --git a/src/components/Hud.tsx b/src/components/Hud.tsx index b8f5dcb..7ef00ef 100644 --- a/src/components/Hud.tsx +++ b/src/components/Hud.tsx @@ -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
Тап по экрану — полёт · уворачивайтесь от астероидов
- + diff --git a/src/components/MainMenu.tsx b/src/components/MainMenu.tsx new file mode 100644 index 0000000..ce2829e --- /dev/null +++ b/src/components/MainMenu.tsx @@ -0,0 +1,31 @@ +export function MainMenu({ + hasSave, + onNewGame, + onContinue, + onSkins, +}: { + hasSave: boolean; + onNewGame: () => void; + onContinue: () => void; + onSkins: () => void; +}) { + return ( +
+
+
Космический Рыцарь
+
Бесконечное приключение среди звёзд
+
+ + + +
+
+
+ ); +} diff --git a/src/components/PauseMenu.tsx b/src/components/PauseMenu.tsx new file mode 100644 index 0000000..eceaae3 --- /dev/null +++ b/src/components/PauseMenu.tsx @@ -0,0 +1,28 @@ +export function PauseMenu({ + onResume, + onSkins, + onMainMenu, +}: { + onResume: () => void; + onSkins: () => void; + onMainMenu: () => void; +}) { + return ( +
+
+
Пауза
+
+ + + +
+
+
+ ); +} diff --git a/src/components/SkinsMenu.tsx b/src/components/SkinsMenu.tsx new file mode 100644 index 0000000..ee9bd3c --- /dev/null +++ b/src/components/SkinsMenu.tsx @@ -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 ( +
+
+
Скины
+
Открываются за прогресс — постоянно усиливают героя
+
+ {SKINS.map((skin) => { + const unlocked = skin.unlockZone === 0 || maxZoneReached >= skin.unlockZone; + const equipped = equippedSkin === skin.id; + return ( +
+
+ +
+
+
{skin.name}
+
{bonusText(skin.bonusType, skin.bonusValue)}
+
{unlocked ? 'Открыт' : `Открывается на зоне ${skin.unlockZone}`}
+
+ +
+ ); + })} +
+ +
+
+ ); +} diff --git a/src/game/GameEngine.ts b/src/game/GameEngine.ts index c382840..97c280f 100644 --- a/src/game/GameEngine.ts +++ b/src/game/GameEngine.ts @@ -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; 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 = { 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; 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 } diff --git a/src/game/constants.ts b/src/game/constants.ts index f3b0811..2caf79e 100644 --- a/src/game/constants.ts +++ b/src/game/constants.ts @@ -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 = { @@ -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 = { + speed: 'скорость', + damage: 'урон', + hp: 'здоровье', + crystalMult: 'заработок за кристаллы', + darkMatterMult: 'силу Тёмной Материи', + droidDamageMult: 'урон дроида', +}; diff --git a/src/game/types.ts b/src/game/types.ts index 985600d..a4ecf73 100644 --- a/src/game/types.ts +++ b/src/game/types.ts @@ -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; }