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
This commit is contained in:
azykov@mail.ru
2026-09-05 16:05:43 +03:00
co-authored by Claude Sonnet 5
parent 0788f42699
commit 799d595e7c
9 changed files with 1177 additions and 58 deletions
+94 -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,57 @@
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);
}
.music-btn {
position: absolute;
top: 10px;
right: 16px;
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;
}
.music-btn:hover {
border-color: #5b8cff;
}
/* ------------------------------------------------------------ boss banner */
.boss-banner {
position: absolute;
@@ -168,11 +221,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 +344,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 +352,12 @@
}
}
@media (max-width: 560px) {
.shop-panel {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
/* --------------------------------------------------------- supernova cutscene */
.supernova-overlay {
position: absolute;
+5 -1
View File
@@ -1,5 +1,6 @@
import { useRef } from 'react';
import { BossBanner } from './components/BossBanner';
import { ChaseBanner } from './components/ChaseBanner';
import { Hud } from './components/Hud';
import { LoreModal } from './components/LoreModal';
import { ShopPanel } from './components/ShopPanel';
@@ -15,8 +16,11 @@ function App() {
<div className="game-root">
<div className="game-viewport">
<canvas ref={canvasRef} className="game-canvas" />
{snapshot && !snapshot.cutsceneActive && <Hud snapshot={snapshot} />}
{snapshot && !snapshot.cutsceneActive && (
<Hud snapshot={snapshot} onToggleMusic={() => engine.current?.toggleMusic()} />
)}
{snapshot?.bossActive && !snapshot.cutsceneActive && <BossBanner snapshot={snapshot} />}
{snapshot?.chaseActive && !snapshot.cutsceneActive && <ChaseBanner snapshot={snapshot} />}
{snapshot?.cutsceneActive && <SupernovaOverlay />}
{snapshot?.loreActive && snapshot.loreEntry && (
<LoreModal entry={snapshot.loreEntry} onDismiss={() => engine.current?.dismissLore()} />
+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>
);
}
+23 -6
View File
@@ -1,8 +1,9 @@
import { formatNumber } from '../game/format';
import type { GameSnapshot } from '../game/types';
export function Hud({ snapshot }: { snapshot: GameSnapshot }) {
export function Hud({ snapshot, onToggleMusic }: { snapshot: GameSnapshot; onToggleMusic: () => 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 +11,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 +45,9 @@ export function Hud({ snapshot }: { snapshot: GameSnapshot }) {
</div>
</div>
<div className="hud-hint">Тап по экрану — полёт · уворачивайтесь от астероидов</div>
<button type="button" className="music-btn" onClick={onToggleMusic}>
{snapshot.musicMuted ? '\u{1F507}' : '\u{1F3B5}'}
</button>
</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,
+792 -36
View File
File diff suppressed because it is too large Load Diff
+64 -8
View File
@@ -4,8 +4,8 @@ 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,39 @@ 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,
} as const;
export const UPGRADES: Record<UpgradeKey, { title: string; desc: string; icon: string; baseCost: number; growth: number; bonus: number }> = {
@@ -69,6 +102,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 +133,31 @@ 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%! Вы открыли врата к Сердцу Галактики. Впереди виден Древний Город, но он полностью окружен армадой ИИ "Ноль". Нажмите "Взрыв Сверхновой", чтобы совершить решающий прыжок с силой Тёмной Материи!',
},
];
+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;
}
}
+52 -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;
@@ -104,4 +145,14 @@ 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;
}