From 6b638b84d079201fa88380ec171b7dd6b378cfb9 Mon Sep 17 00:00:00 2001 From: "azykov@mail.ru" Date: Mon, 7 Sep 2026 10:16:46 +0300 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01D5SuZW1XGCkahPX7cHMoRP --- src/App.css | 176 ++++++++++++++++++++++++++++++++++- src/App.tsx | 64 +++++++++++-- src/components/Hud.tsx | 15 ++- src/components/MainMenu.tsx | 31 ++++++ src/components/PauseMenu.tsx | 28 ++++++ src/components/SkinsMenu.tsx | 56 +++++++++++ src/game/GameEngine.ts | 156 +++++++++++++++++++++++++++---- src/game/constants.ts | 86 ++++++++++++++++- src/game/types.ts | 19 ++++ 9 files changed, 597 insertions(+), 34 deletions(-) create mode 100644 src/components/MainMenu.tsx create mode 100644 src/components/PauseMenu.tsx create mode 100644 src/components/SkinsMenu.tsx 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; }