Compare commits

...
3 Commits
Author SHA1 Message Date
azykov@mail.ruandClaude Sonnet 5 6b638b84d0 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
2026-09-07 10:16:46 +03:00
azykov@mail.ru 86fd7b8e70 host at 0.0.0.0:8888 2026-09-05 16:07:29 +03:00
azykov@mail.ruandClaude Sonnet 5 799d595e7c Add knight health, boss attack patterns, chases, and music
Knight now has a hit-bar (pirates deal 1, bosses deal 3 damage) with
a new vitality upgrade. Bosses grow bigger/scarier and cycle through
melee/missile/bomb/laser attack patterns; boss HP is now exactly 3x
the local pirate's. Zones 20 and 40 replace their boss with a unique
chase (Pirate Cruiser laser dodge + fuel cell collection; Black Swarm
plasma dodge with a tap-to-escape engulf event). Pirates spawn 5x
faster and got a bandana/eyepatch redesign. Added a procedural music
engine, a visible destination galaxy in the supernova cutscene, and
expanded the Data Archive lore to trigger at zones 10/20/40/60/80/100.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D5SuZW1XGCkahPX7cHMoRP
2026-09-05 16:05:43 +03:00
13 changed files with 1769 additions and 83 deletions
+264 -2
View File
@@ -41,6 +41,8 @@
display: flex;
justify-content: space-between;
gap: 12px;
padding-right: 44px;
box-sizing: border-box;
}
.hud-hint {
@@ -113,6 +115,64 @@
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
}
.hp-bar {
position: relative;
width: 100%;
height: 18px;
margin-top: 4px;
border-radius: 9px;
background: rgba(255, 255, 255, 0.12);
overflow: hidden;
}
.hp-bar-fill {
position: absolute;
inset: 0;
width: 0%;
background: linear-gradient(90deg, #ff5a7a, #7bffb0);
transition: width 0.15s ease-out;
}
.hp-bar-text {
position: relative;
display: block;
text-align: center;
font-size: 11px;
line-height: 18px;
color: #fff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
}
.icon-btn {
position: absolute;
top: 10px;
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(11, 13, 32, 0.6);
color: #eef1ff;
font-size: 15px;
line-height: 1;
cursor: pointer;
pointer-events: auto;
display: flex;
align-items: center;
justify-content: center;
}
.icon-btn:hover {
border-color: #5b8cff;
}
.music-btn {
right: 16px;
}
.pause-btn {
right: 56px;
}
/* ------------------------------------------------------------ boss banner */
.boss-banner {
position: absolute;
@@ -168,11 +228,44 @@
color: #ffd7de;
}
/* ----------------------------------------------------------- chase banner */
.chase-banner {
position: absolute;
top: 76px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 10px 20px;
border-radius: 10px;
background: rgba(20, 8, 20, 0.6);
border: 1px solid rgba(180, 90, 255, 0.5);
pointer-events: none;
}
.chase-title {
font-weight: 700;
color: #d7b3ff;
letter-spacing: 0.04em;
}
.chase-sub {
font-size: 13px;
color: #ffdede;
}
.chase-hint {
font-size: 12px;
color: #c8ceec;
}
/* -------------------------------------------------------------- shop panel */
.shop-panel {
flex: 0 0 auto;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 8px;
padding: 10px;
background: #0b0d20;
@@ -258,7 +351,7 @@
@media (max-width: 860px) {
.shop-panel {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.hud-zone {
@@ -266,6 +359,12 @@
}
}
@media (max-width: 560px) {
.shop-panel {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
/* --------------------------------------------------------- supernova cutscene */
.supernova-overlay {
position: absolute;
@@ -391,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;
}
+58 -6
View File
@@ -1,28 +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} />}
{snapshot?.bossActive && !snapshot.cutsceneActive && <BossBanner snapshot={snapshot} />}
{snapshot?.cutsceneActive && <SupernovaOverlay />}
{snapshot?.loreActive && snapshot.loreEntry && (
{screen === 'game' && snapshot && !snapshot.cutsceneActive && (
<Hud
snapshot={snapshot}
onToggleMusic={() => engine.current?.toggleMusic()}
onPause={() => engine.current?.setPaused(true)}
/>
)}
{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)}
+25
View File
@@ -0,0 +1,25 @@
import type { GameSnapshot } from '../game/types';
export function ChaseBanner({ snapshot }: { snapshot: GameSnapshot }) {
if (!snapshot.chaseActive) return null;
if (snapshot.chaseKind === 'cruiser') {
return (
<div className="chase-banner">
<div className="chase-title">Погоня: Пиратский Крейсер</div>
<div className="chase-sub">
Топливных ячеек: {snapshot.chaseFuelCells}/{snapshot.chaseFuelRequired}
</div>
<div className="chase-hint">Уворачивайтесь от лазеров и астероидов</div>
</div>
);
}
return (
<div className="chase-banner">
<div className="chase-title">Погоня: Чёрный Рой</div>
<div className="chase-sub">Осталось: {Math.ceil(snapshot.chaseTimeRemaining)} с</div>
<div className="chase-hint">Уворачивайтесь от плазмы</div>
</div>
);
}
+34 -6
View File
@@ -1,8 +1,17 @@
import { formatNumber } from '../game/format';
import type { GameSnapshot } from '../game/types';
export function Hud({ snapshot }: { snapshot: GameSnapshot }) {
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 (
<div className="hud">
<div className="hud-row">
@@ -10,19 +19,32 @@ export function Hud({ snapshot }: { snapshot: GameSnapshot }) {
<span className="hud-label">Очки</span>
<span className="hud-value">{formatNumber(snapshot.score)}</span>
</div>
<div className="hud-item">
<span className="hud-label">Здоровье</span>
<div className="hp-bar">
<div className="hp-bar-fill" style={{ width: `${hpPct}%` }} />
<span className="hp-bar-text">
{Math.ceil(snapshot.knightHp)}/{Math.ceil(snapshot.knightMaxHp)}
</span>
</div>
</div>
<div className="hud-item hud-zone">
<span className="hud-label">Зона {snapshot.zone}</span>
{!snapshot.bossActive ? (
{snapshot.bossActive ? (
<div className="zone-progress zone-progress--boss">
<span className="zone-progress-text">Битва с боссом</span>
</div>
) : snapshot.chaseActive ? (
<div className="zone-progress zone-progress--boss">
<span className="zone-progress-text">Погоня!</span>
</div>
) : (
<div className="zone-progress">
<div className="zone-progress-fill" style={{ width: `${progressPct}%` }} />
<span className="zone-progress-text">
{snapshot.crystalsInZone}/{snapshot.crystalsRequired} кристаллов
</span>
</div>
) : (
<div className="zone-progress zone-progress--boss">
<span className="zone-progress-text">Битва с боссом</span>
</div>
)}
</div>
<div className="hud-item">
@@ -31,6 +53,12 @@ export function Hud({ snapshot }: { snapshot: GameSnapshot }) {
</div>
</div>
<div className="hud-hint">Тап по экрану — полёт · уворачивайтесь от астероидов</div>
<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>
);
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { UPGRADES } from '../game/constants';
import { formatNumber } from '../game/format';
import type { GameSnapshot, UpgradeKey } from '../game/types';
const KEYS: UpgradeKey[] = ['crystal', 'speed', 'damage', 'droid'];
const KEYS: UpgradeKey[] = ['crystal', 'speed', 'damage', 'droid', 'vitality'];
export function ShopPanel({
snapshot,
+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>
);
}
+930 -58
View File
File diff suppressed because it is too large Load Diff
+149 -9
View File
@@ -1,11 +1,11 @@
import type { LoreEntry, UpgradeKey } from './types';
import type { LoreEntry, SkinDef, UpgradeKey } from './types';
export const WORLD = {
CRYSTALS_PER_ZONE: 10,
MAX_CRYSTALS_ON_SCREEN: 6,
CRYSTAL_SPAWN_INTERVAL: 1.1,
PIRATE_SPAWN_INTERVAL: 15,
BOSS_HP_MULT: 5,
PIRATE_SPAWN_INTERVAL: 3,
BOSS_HP_MULT: 3,
CONTACT_RANGE: 46,
COLLECT_RANGE: 26,
PIRATE_HP_ZONE_MULT: 1.2,
@@ -34,6 +34,41 @@ export const WORLD = {
GOLDEN_ASTEROID_REWARD_MULT: 20,
SUPERNOVA_DURATION: 10,
SUPERNOVA_DARK_MATTER_GAIN: 1,
BASE_KNIGHT_MAX_HP: 10,
PIRATE_CONTACT_DAMAGE: 1,
BOSS_HIT_DAMAGE: 3,
HIT_INVULN_DURATION: 0.6,
BOSS_BASE_RADIUS: 48,
BOSS_RADIUS_PER_TIER: 6,
BOSS_MAX_RADIUS: 96,
BOSS_MELEE_ATTACK_INTERVAL: 1.3,
BOSS_MISSILE_INTERVAL: 2.4,
BOSS_MISSILE_SPEED: 150,
BOSS_MISSILE_RADIUS: 9,
BOSS_BOMB_INTERVAL: 3.4,
BOSS_BOMB_TELEGRAPH: 1.3,
BOSS_BOMB_EXPLODE_DURATION: 0.35,
BOSS_BOMB_RADIUS: 62,
BOSS_LASER_ROTATE_SPEED: 1.6,
BOSS_LASER_LENGTH: 420,
BOSS_LASER_WIDTH: 16,
CHASE_LASER_INTERVAL: 2,
CHASE_LASER_SPEED: 260,
CHASE_LASER_RADIUS: 8,
CHASE_LASER_DAMAGE_RATIO: 0.15,
CHASE_PLASMA_INTERVAL: 1.6,
CHASE_PLASMA_SPEED: 220,
CHASE_PLASMA_RADIUS: 12,
CHASE_PLASMA_DAMAGE_RATIO: 0.2,
CRUISER_FUEL_CELLS_REQUIRED: 10,
FUEL_CELL_MAX_ON_SCREEN: 4,
FUEL_CELL_SPAWN_INTERVAL: 1.4,
SWARM_DURATION: 40,
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 }> = {
@@ -69,6 +104,14 @@ export const UPGRADES: Record<UpgradeKey, { title: string; desc: string; icon: s
growth: 1.65,
bonus: 0,
},
vitality: {
title: 'Укрепление Скафандра',
desc: '+50% к максимальному здоровью, лечит при покупке',
icon: '\u{1F6E1}\u{FE0F}',
baseCost: 50,
growth: 1.2,
bonus: 0.5,
},
};
export interface DroidTierConfig {
@@ -92,16 +135,113 @@ export const LORE_ENTRIES: LoreEntry[] = [
{
zone: 10,
title: 'Кто такой Рыцарь',
text: 'Архив Данных: Сканирование памяти завершено. Этот Рыцарь — последний уцелевший пилот из древнего Ордена Звёздных Защитников. Его цивилизация исчезла много веков назад, но его высокотехнологичный скафандр сохранил пилота в режиме анабиоза, чтобы он исполнил финальную миссию.',
text: 'Архив Данных: Сканирование памяти завершено. Этот Рыцарь — последний уцелевший пилот из древнего Ордена Звёздных Защитников. Его цивилизация исчезла много веков назад, но высокотехнологичный скафандр сохранил пилота в режиме анабиоза, чтобы он исполнил финальную миссию.',
},
{
zone: 20,
title: 'Что за кристаллы',
text: 'Архив Данных: Обнаружена информация о кристаллах. Это не просто камни, а Хроно-Кристаллы — спрессованная чистая энергия погибших звёзд. В них записана история и координаты древней столицы галактики. Рыцарь собирает их, чтобы запустить Главный Звёздный Реактор.',
title: 'Крейсер уничтожен',
text: 'Архив Данных: Крейсер уничтожен! Вы смогли расшифровать данные с его бортового компьютера. Оказывается, Космические Кристаллы — это Хроно-Кристаллы, спрессованная энергия погибших звёзд. Рыцарь собирает их, чтобы запустить Главный Звёздный Реактор.',
},
{
zone: 30,
title: 'Кто такие Пираты',
text: 'Архив Данных: Анализ противника завершен. Пираты-Желейки — это биомеханические дроны, созданные заброшенными автоматическими заводами Тёмной туманности. У них нет разума, их цель — поглощать энергию Хроно-Кристаллов, чтобы строить новые армии. Их нужно остановить любой ценой!',
zone: 40,
title: 'Вырвались из Облака',
text: 'Архив Данных: Вы вырвались из Черного Облака! Это нанороботы ИИ "Ноль", который уничтожает галактику. Но теперь ваша ракета при Взрыве Сверхновой точно знает путь в параллельные измерения. Каждая Тёмная Материя — это ключ к скрытым базам Ордена, где спрятано мощное оружие.',
},
{
zone: 60,
title: 'Сверхразум "Ноль"',
text: 'Архив Данных: Поймана закодированная радиопередача. Пираты-Желейки не просто собирают кристаллы. Оказывается, глубоко в Тёмной туманности ожил Сверхразум — заброшенный военный ИИ под названием "Ноль". Это он отправляет дронов и пытается перегрузить реакторы галактики.',
},
{
zone: 80,
title: 'Тайна скафандра',
text: 'Архив Данных: Обнаружен скрытый лог-файл в шлеме Рыцаря. Высокотехнологичный скафандр был создан не для войны. Это спасательная капсула, внутри которой запечатано сознание создателя Ордена Защитников. Костюм защищает пилота, пока тот собирает энергию.',
},
{
zone: 100,
title: 'Врата к Сердцу Галактики',
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: 'урон дроида',
};
+118
View File
@@ -0,0 +1,118 @@
type Note = [frequency: number, duration: number];
const MELODY: Note[] = [
[392.0, 0.25],
[440.0, 0.25],
[523.25, 0.25],
[440.0, 0.25],
[349.23, 0.25],
[392.0, 0.25],
[329.63, 0.25],
[392.0, 0.5],
[440.0, 0.25],
[523.25, 0.25],
[587.33, 0.25],
[523.25, 0.25],
[440.0, 0.25],
[392.0, 0.25],
[349.23, 0.25],
[329.63, 0.5],
];
const BASS: Note[] = [
[130.81, 1],
[174.61, 1],
[146.83, 1],
[164.81, 1],
];
export class MusicEngine {
private ctx: AudioContext | null = null;
private gain: GainNode | null = null;
private muted = false;
private loopHandle: number | null = null;
private ensureContext() {
if (!this.ctx) {
const Ctx = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
this.ctx = new Ctx();
this.gain = this.ctx.createGain();
this.gain.gain.value = this.muted ? 0 : 0.09;
this.gain.connect(this.ctx.destination);
}
if (this.ctx.state === 'suspended') {
void this.ctx.resume();
}
}
start() {
this.ensureContext();
if (this.loopHandle !== null) return;
this.scheduleLoop();
}
private scheduleLoop = () => {
const ctx = this.ctx;
const gain = this.gain;
if (!ctx || !gain) return;
const startTime = ctx.currentTime + 0.05;
let t = startTime;
for (const [freq, dur] of MELODY) {
this.playNote(freq, t, dur * 0.85, 'square', 0.55);
t += dur;
}
const loopDuration = t - startTime;
let bt = startTime;
while (bt < startTime + loopDuration) {
for (const [freq, dur] of BASS) {
if (bt >= startTime + loopDuration) break;
this.playNote(freq, bt, dur * 0.9, 'triangle', 0.7);
bt += dur;
}
}
this.loopHandle = window.setTimeout(() => {
this.loopHandle = null;
this.scheduleLoop();
}, loopDuration * 1000);
};
private playNote(freq: number, time: number, dur: number, type: OscillatorType, level: number) {
const ctx = this.ctx;
const gain = this.gain;
if (!ctx || !gain) return;
const osc = ctx.createOscillator();
const noteGain = ctx.createGain();
osc.type = type;
osc.frequency.value = freq;
noteGain.gain.setValueAtTime(0, time);
noteGain.gain.linearRampToValueAtTime(level, time + 0.02);
noteGain.gain.linearRampToValueAtTime(0, time + dur);
osc.connect(noteGain);
noteGain.connect(gain);
osc.start(time);
osc.stop(time + dur + 0.05);
}
toggleMute(): boolean {
this.muted = !this.muted;
if (this.gain) this.gain.gain.value = this.muted ? 0 : 0.09;
return this.muted;
}
isMuted(): boolean {
return this.muted;
}
stop() {
if (this.loopHandle !== null) {
window.clearTimeout(this.loopHandle);
this.loopHandle = null;
}
this.ctx?.close();
this.ctx = null;
this.gain = null;
}
}
+71 -1
View File
@@ -1,4 +1,6 @@
export type UpgradeKey = 'crystal' | 'speed' | 'damage' | 'droid';
export type UpgradeKey = 'crystal' | 'speed' | 'damage' | 'droid' | 'vitality';
export type BossType = 'melee' | 'missile' | 'bomb' | 'laser';
export interface CrystalEntity {
id: number;
@@ -56,6 +58,45 @@ export interface GoldenAsteroidEntity {
jitter: number[];
}
export interface BossProjectile {
id: number;
x: number;
y: number;
vx: number;
vy: number;
radius: number;
}
export interface BossBomb {
x: number;
y: number;
radius: number;
state: 'telegraph' | 'boom';
timer: number;
}
export type ChaseKind = 'cruiser' | 'swarm';
export interface FuelCellEntity {
id: number;
x: number;
y: number;
radius: number;
rotation: number;
spin: number;
jitter: number[];
}
export interface ChaseProjectile {
id: number;
kind: 'laser' | 'plasma';
x: number;
y: number;
vx: number;
vy: number;
radius: number;
}
export interface Particle {
x: number;
y: number;
@@ -83,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;
@@ -104,4 +158,20 @@ export interface GameSnapshot {
cutsceneActive: boolean;
loreActive: boolean;
loreEntry: LoreEntry | null;
knightHp: number;
knightMaxHp: number;
musicMuted: boolean;
chaseActive: boolean;
chaseKind: ChaseKind | null;
chaseFuelCells: number;
chaseFuelRequired: number;
chaseTimeRemaining: number;
chaseEngulfed: boolean;
chaseEscapeMeter: number;
chaseDriftActive: boolean;
chaseDriftRemaining: number;
paused: boolean;
maxZoneReached: number;
equippedSkin: string;
hasSave: boolean;
}
+4
View File
@@ -4,4 +4,8 @@ import { defineConfig } from 'vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 8888,
}
})