Compare commits
3
Commits
0788f42699
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b638b84d0 | ||
|
|
86fd7b8e70 | ||
|
|
799d595e7c |
+264
-2
@@ -41,6 +41,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
padding-right: 44px;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hud-hint {
|
.hud-hint {
|
||||||
@@ -113,6 +115,64 @@
|
|||||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
|
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 */
|
||||||
.boss-banner {
|
.boss-banner {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -168,11 +228,44 @@
|
|||||||
color: #ffd7de;
|
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 */
|
||||||
.shop-panel {
|
.shop-panel {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background: #0b0d20;
|
background: #0b0d20;
|
||||||
@@ -258,7 +351,7 @@
|
|||||||
|
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
.shop-panel {
|
.shop-panel {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.hud-zone {
|
.hud-zone {
|
||||||
@@ -266,6 +359,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.shop-panel {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------- supernova cutscene */
|
/* --------------------------------------------------------- supernova cutscene */
|
||||||
.supernova-overlay {
|
.supernova-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -391,3 +490,166 @@
|
|||||||
transform: translateY(0) scale(1);
|
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
@@ -1,28 +1,80 @@
|
|||||||
import { useRef } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { BossBanner } from './components/BossBanner';
|
import { BossBanner } from './components/BossBanner';
|
||||||
|
import { ChaseBanner } from './components/ChaseBanner';
|
||||||
import { Hud } from './components/Hud';
|
import { Hud } from './components/Hud';
|
||||||
import { LoreModal } from './components/LoreModal';
|
import { LoreModal } from './components/LoreModal';
|
||||||
|
import { MainMenu } from './components/MainMenu';
|
||||||
|
import { PauseMenu } from './components/PauseMenu';
|
||||||
import { ShopPanel } from './components/ShopPanel';
|
import { ShopPanel } from './components/ShopPanel';
|
||||||
|
import { SkinsMenu } from './components/SkinsMenu';
|
||||||
import { SupernovaOverlay } from './components/SupernovaOverlay';
|
import { SupernovaOverlay } from './components/SupernovaOverlay';
|
||||||
import { useGameEngine } from './hooks/useGameEngine';
|
import { useGameEngine } from './hooks/useGameEngine';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
|
type Screen = 'main' | 'skins' | 'game';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const { engine, snapshot } = useGameEngine(canvasRef);
|
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 (
|
return (
|
||||||
<div className="game-root">
|
<div className="game-root">
|
||||||
<div className="game-viewport">
|
<div className="game-viewport">
|
||||||
<canvas ref={canvasRef} className="game-canvas" />
|
<canvas ref={canvasRef} className="game-canvas" />
|
||||||
{snapshot && !snapshot.cutsceneActive && <Hud snapshot={snapshot} />}
|
{screen === 'game' && snapshot && !snapshot.cutsceneActive && (
|
||||||
{snapshot?.bossActive && !snapshot.cutsceneActive && <BossBanner snapshot={snapshot} />}
|
<Hud
|
||||||
{snapshot?.cutsceneActive && <SupernovaOverlay />}
|
snapshot={snapshot}
|
||||||
{snapshot?.loreActive && snapshot.loreEntry && (
|
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()} />
|
<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>
|
</div>
|
||||||
{snapshot && (
|
{screen === 'game' && snapshot && (
|
||||||
<ShopPanel
|
<ShopPanel
|
||||||
snapshot={snapshot}
|
snapshot={snapshot}
|
||||||
onBuy={(key) => engine.current?.buyUpgrade(key)}
|
onBuy={(key) => engine.current?.buyUpgrade(key)}
|
||||||
|
|||||||
@@ -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
@@ -1,8 +1,17 @@
|
|||||||
import { formatNumber } from '../game/format';
|
import { formatNumber } from '../game/format';
|
||||||
import type { GameSnapshot } from '../game/types';
|
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 progressPct = Math.min(100, (snapshot.crystalsInZone / snapshot.crystalsRequired) * 100);
|
||||||
|
const hpPct = Math.min(100, (snapshot.knightHp / snapshot.knightMaxHp) * 100);
|
||||||
return (
|
return (
|
||||||
<div className="hud">
|
<div className="hud">
|
||||||
<div className="hud-row">
|
<div className="hud-row">
|
||||||
@@ -10,19 +19,32 @@ export function Hud({ snapshot }: { snapshot: GameSnapshot }) {
|
|||||||
<span className="hud-label">Очки</span>
|
<span className="hud-label">Очки</span>
|
||||||
<span className="hud-value">{formatNumber(snapshot.score)}</span>
|
<span className="hud-value">{formatNumber(snapshot.score)}</span>
|
||||||
</div>
|
</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">
|
<div className="hud-item hud-zone">
|
||||||
<span className="hud-label">Зона {snapshot.zone}</span>
|
<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">
|
||||||
<div className="zone-progress-fill" style={{ width: `${progressPct}%` }} />
|
<div className="zone-progress-fill" style={{ width: `${progressPct}%` }} />
|
||||||
<span className="zone-progress-text">
|
<span className="zone-progress-text">
|
||||||
{snapshot.crystalsInZone}/{snapshot.crystalsRequired} кристаллов
|
{snapshot.crystalsInZone}/{snapshot.crystalsRequired} кристаллов
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="zone-progress zone-progress--boss">
|
|
||||||
<span className="zone-progress-text">Битва с боссом</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="hud-item">
|
<div className="hud-item">
|
||||||
@@ -31,6 +53,12 @@ export function Hud({ snapshot }: { snapshot: GameSnapshot }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="hud-hint">Тап по экрану — полёт · уворачивайтесь от астероидов</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { UPGRADES } from '../game/constants';
|
|||||||
import { formatNumber } from '../game/format';
|
import { formatNumber } from '../game/format';
|
||||||
import type { GameSnapshot, UpgradeKey } from '../game/types';
|
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({
|
export function ShopPanel({
|
||||||
snapshot,
|
snapshot,
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+149
-9
@@ -1,11 +1,11 @@
|
|||||||
import type { LoreEntry, UpgradeKey } from './types';
|
import type { LoreEntry, SkinDef, UpgradeKey } from './types';
|
||||||
|
|
||||||
export const WORLD = {
|
export const WORLD = {
|
||||||
CRYSTALS_PER_ZONE: 10,
|
CRYSTALS_PER_ZONE: 10,
|
||||||
MAX_CRYSTALS_ON_SCREEN: 6,
|
MAX_CRYSTALS_ON_SCREEN: 6,
|
||||||
CRYSTAL_SPAWN_INTERVAL: 1.1,
|
CRYSTAL_SPAWN_INTERVAL: 1.1,
|
||||||
PIRATE_SPAWN_INTERVAL: 15,
|
PIRATE_SPAWN_INTERVAL: 3,
|
||||||
BOSS_HP_MULT: 5,
|
BOSS_HP_MULT: 3,
|
||||||
CONTACT_RANGE: 46,
|
CONTACT_RANGE: 46,
|
||||||
COLLECT_RANGE: 26,
|
COLLECT_RANGE: 26,
|
||||||
PIRATE_HP_ZONE_MULT: 1.2,
|
PIRATE_HP_ZONE_MULT: 1.2,
|
||||||
@@ -34,6 +34,41 @@ export const WORLD = {
|
|||||||
GOLDEN_ASTEROID_REWARD_MULT: 20,
|
GOLDEN_ASTEROID_REWARD_MULT: 20,
|
||||||
SUPERNOVA_DURATION: 10,
|
SUPERNOVA_DURATION: 10,
|
||||||
SUPERNOVA_DARK_MATTER_GAIN: 1,
|
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;
|
} as const;
|
||||||
|
|
||||||
export const UPGRADES: Record<UpgradeKey, { title: string; desc: string; icon: string; baseCost: number; growth: number; bonus: number }> = {
|
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,
|
growth: 1.65,
|
||||||
bonus: 0,
|
bonus: 0,
|
||||||
},
|
},
|
||||||
|
vitality: {
|
||||||
|
title: 'Укрепление Скафандра',
|
||||||
|
desc: '+50% к максимальному здоровью, лечит при покупке',
|
||||||
|
icon: '\u{1F6E1}\u{FE0F}',
|
||||||
|
baseCost: 50,
|
||||||
|
growth: 1.2,
|
||||||
|
bonus: 0.5,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface DroidTierConfig {
|
export interface DroidTierConfig {
|
||||||
@@ -92,16 +135,113 @@ export const LORE_ENTRIES: LoreEntry[] = [
|
|||||||
{
|
{
|
||||||
zone: 10,
|
zone: 10,
|
||||||
title: 'Кто такой Рыцарь',
|
title: 'Кто такой Рыцарь',
|
||||||
text: 'Архив Данных: Сканирование памяти завершено. Этот Рыцарь — последний уцелевший пилот из древнего Ордена Звёздных Защитников. Его цивилизация исчезла много веков назад, но его высокотехнологичный скафандр сохранил пилота в режиме анабиоза, чтобы он исполнил финальную миссию.',
|
text: 'Архив Данных: Сканирование памяти завершено. Этот Рыцарь — последний уцелевший пилот из древнего Ордена Звёздных Защитников. Его цивилизация исчезла много веков назад, но высокотехнологичный скафандр сохранил пилота в режиме анабиоза, чтобы он исполнил финальную миссию.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
zone: 20,
|
zone: 20,
|
||||||
title: 'Что за кристаллы',
|
title: 'Крейсер уничтожен',
|
||||||
text: 'Архив Данных: Обнаружена информация о кристаллах. Это не просто камни, а Хроно-Кристаллы — спрессованная чистая энергия погибших звёзд. В них записана история и координаты древней столицы галактики. Рыцарь собирает их, чтобы запустить Главный Звёздный Реактор.',
|
text: 'Архив Данных: Крейсер уничтожен! Вы смогли расшифровать данные с его бортового компьютера. Оказывается, Космические Кристаллы — это Хроно-Кристаллы, спрессованная энергия погибших звёзд. Рыцарь собирает их, чтобы запустить Главный Звёздный Реактор.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
zone: 30,
|
zone: 40,
|
||||||
title: 'Кто такие Пираты',
|
title: 'Вырвались из Облака',
|
||||||
text: 'Архив Данных: Анализ противника завершен. Пираты-Желейки — это биомеханические дроны, созданные заброшенными автоматическими заводами Тёмной туманности. У них нет разума, их цель — поглощать энергию Хроно-Кристаллов, чтобы строить новые армии. Их нужно остановить любой ценой!',
|
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: 'урон дроида',
|
||||||
|
};
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
export interface CrystalEntity {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -56,6 +58,45 @@ export interface GoldenAsteroidEntity {
|
|||||||
jitter: number[];
|
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 {
|
export interface Particle {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@@ -83,6 +124,19 @@ export interface LoreEntry {
|
|||||||
text: string;
|
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 {
|
export interface GameSnapshot {
|
||||||
score: number;
|
score: number;
|
||||||
zone: number;
|
zone: number;
|
||||||
@@ -104,4 +158,20 @@ export interface GameSnapshot {
|
|||||||
cutsceneActive: boolean;
|
cutsceneActive: boolean;
|
||||||
loreActive: boolean;
|
loreActive: boolean;
|
||||||
loreEntry: LoreEntry | null;
|
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,4 +4,8 @@ import { defineConfig } from 'vite'
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 8888,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user