diff --git a/index.html b/index.html
index e71c73e..869ad4a 100644
--- a/index.html
+++ b/index.html
@@ -1,10 +1,10 @@
-
+
- space-knight
+ Космический Рыцарь
diff --git a/public/icons.svg b/public/icons.svg
deleted file mode 100644
index e952219..0000000
--- a/public/icons.svg
+++ /dev/null
@@ -1,24 +0,0 @@
-
diff --git a/src/App.css b/src/App.css
index f90339d..61b8267 100644
--- a/src/App.css
+++ b/src/App.css
@@ -1,184 +1,307 @@
-.counter {
- font-size: 16px;
- padding: 5px 10px;
- border-radius: 5px;
- color: var(--accent);
- background: var(--accent-bg);
- border: 2px solid transparent;
- transition: border-color 0.3s;
- margin-bottom: 24px;
-
- &:hover {
- border-color: var(--accent-border);
- }
- &:focus-visible {
- outline: 2px solid var(--accent);
- outline-offset: 2px;
- }
-}
-
-.hero {
- position: relative;
-
- .base,
- .framework,
- .vite {
- inset-inline: 0;
- margin: 0 auto;
- }
-
- .base {
- width: 170px;
- position: relative;
- z-index: 0;
- }
-
- .framework,
- .vite {
- position: absolute;
- }
-
- .framework {
- z-index: 1;
- top: 34px;
- height: 28px;
- transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
- scale(1.4);
- }
-
- .vite {
- z-index: 0;
- top: 107px;
- height: 26px;
- width: auto;
- transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
- scale(0.8);
- }
-}
-
-#center {
+.game-root {
display: flex;
flex-direction: column;
- gap: 25px;
- place-content: center;
- place-items: center;
- flex-grow: 1;
-
- @media (max-width: 1024px) {
- padding: 32px 20px 24px;
- gap: 18px;
- }
+ width: 100%;
+ height: 100svh;
+ background: #05060f;
+ color: #eef1ff;
+ font-family: system-ui, 'Segoe UI', Roboto, sans-serif;
+ overflow: hidden;
}
-#next-steps {
+.game-viewport {
+ position: relative;
+ flex: 1 1 auto;
+ min-height: 0;
+}
+
+.game-canvas {
+ display: block;
+ width: 100%;
+ height: 100%;
+ cursor: crosshair;
+ touch-action: none;
+}
+
+/* ---------------------------------------------------------------- HUD */
+.hud {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
display: flex;
- border-top: 1px solid var(--border);
- text-align: left;
-
- & > div {
- flex: 1 1 0;
- padding: 32px;
- @media (max-width: 1024px) {
- padding: 24px 20px;
- }
- }
-
- .icon {
- margin-bottom: 16px;
- width: 22px;
- height: 22px;
- }
-
- @media (max-width: 1024px) {
- flex-direction: column;
- text-align: center;
- }
+ flex-direction: column;
+ gap: 4px;
+ padding: 10px 16px;
+ pointer-events: none;
+ background: linear-gradient(to bottom, rgba(5, 6, 15, 0.75), transparent);
}
-#docs {
- border-right: 1px solid var(--border);
-
- @media (max-width: 1024px) {
- border-right: none;
- border-bottom: 1px solid var(--border);
- }
-}
-
-#next-steps ul {
- list-style: none;
- padding: 0;
+.hud-row {
display: flex;
- gap: 8px;
- margin: 32px 0 0;
-
- .logo {
- height: 18px;
- }
-
- a {
- color: var(--text-h);
- font-size: 16px;
- border-radius: 6px;
- background: var(--social-bg);
- display: flex;
- padding: 6px 12px;
- align-items: center;
- gap: 8px;
- text-decoration: none;
- transition: box-shadow 0.3s;
-
- &:hover {
- box-shadow: var(--shadow);
- }
- .button-icon {
- height: 18px;
- width: 18px;
- }
- }
-
- @media (max-width: 1024px) {
- margin-top: 20px;
- flex-wrap: wrap;
- justify-content: center;
-
- li {
- flex: 1 1 calc(50% - 8px);
- }
-
- a {
- width: 100%;
- justify-content: center;
- box-sizing: border-box;
- }
- }
+ justify-content: space-between;
+ gap: 12px;
}
-#spacer {
- height: 88px;
- border-top: 1px solid var(--border);
- @media (max-width: 1024px) {
- height: 48px;
- }
+.hud-hint {
+ align-self: center;
+ font-size: 11px;
+ color: rgba(238, 241, 255, 0.55);
+ letter-spacing: 0.02em;
}
-.ticks {
+.hud-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ min-width: 110px;
+}
+
+.hud-zone {
+ min-width: 220px;
+}
+
+.hud-label {
+ font-size: 11px;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: #8d94c2;
+}
+
+.hud-value {
+ font-size: 20px;
+ font-weight: 700;
+ color: #e7ebff;
+}
+
+.hud-value--dm {
+ color: #c084fc;
+}
+
+.zone-progress {
position: relative;
width: 100%;
+ height: 18px;
+ margin-top: 4px;
+ border-radius: 9px;
+ background: rgba(255, 255, 255, 0.12);
+ overflow: hidden;
+}
- &::before,
- &::after {
- content: '';
- position: absolute;
- top: -4.5px;
- border: 5px solid transparent;
+.zone-progress--boss {
+ background: rgba(255, 90, 122, 0.25);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.zone-progress-fill {
+ position: absolute;
+ inset: 0;
+ width: 0%;
+ background: linear-gradient(90deg, #5b8cff, #9b5bff);
+ transition: width 0.15s ease-out;
+}
+
+.zone-progress-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);
+}
+
+/* ------------------------------------------------------------ boss banner */
+.boss-banner {
+ position: absolute;
+ top: 76px;
+ left: 50%;
+ transform: translateX(-50%);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+ padding: 10px 20px;
+ border-radius: 10px;
+ background: rgba(20, 8, 20, 0.6);
+ border: 1px solid rgba(255, 90, 122, 0.5);
+ pointer-events: none;
+}
+
+.boss-title {
+ font-weight: 700;
+ color: #ffb3c2;
+ letter-spacing: 0.04em;
+}
+
+.boss-hp-bar {
+ position: relative;
+ width: 260px;
+ height: 16px;
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.12);
+ overflow: hidden;
+}
+
+.boss-hp-fill {
+ position: absolute;
+ inset: 0;
+ width: 0%;
+ background: linear-gradient(90deg, #ff5a7a, #ff9a3c);
+ transition: width 0.15s ease-out;
+}
+
+.boss-hp-text {
+ position: relative;
+ display: block;
+ text-align: center;
+ font-size: 11px;
+ line-height: 16px;
+ color: #fff;
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
+}
+
+.boss-hint {
+ font-size: 12px;
+ color: #ffd7de;
+}
+
+/* -------------------------------------------------------------- shop panel */
+.shop-panel {
+ flex: 0 0 auto;
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 8px;
+ padding: 10px;
+ background: #0b0d20;
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+.shop-btn,
+.prestige-btn {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 10px;
+ border-radius: 8px;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ background: #151834;
+ color: #eef1ff;
+ cursor: pointer;
+ text-align: left;
+ font: inherit;
+ transition: border-color 0.15s, transform 0.1s;
+}
+
+.shop-btn:hover:not(:disabled),
+.prestige-btn:hover:not(:disabled) {
+ border-color: #5b8cff;
+ transform: translateY(-1px);
+}
+
+.shop-btn--disabled,
+.prestige-btn--locked {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+
+.shop-icon {
+ font-size: 22px;
+ flex: 0 0 auto;
+}
+
+.shop-body {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ flex: 1 1 auto;
+}
+
+.shop-title {
+ font-size: 12px;
+ font-weight: 700;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.shop-desc {
+ font-size: 10.5px;
+ color: #9aa1cc;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.shop-level {
+ font-size: 10.5px;
+ color: #7bffb0;
+}
+
+.shop-cost {
+ font-size: 12px;
+ font-weight: 700;
+ color: #8ef7ff;
+ flex: 0 0 auto;
+}
+
+.prestige-btn {
+ background: linear-gradient(135deg, #2a1240, #150826);
+ border-color: rgba(192, 132, 252, 0.5);
+}
+
+.prestige-btn .shop-desc {
+ color: #d7b8ff;
+}
+
+@media (max-width: 860px) {
+ .shop-panel {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
}
- &::before {
- left: 0;
- border-left-color: var(--border);
+ .hud-zone {
+ min-width: 140px;
}
- &::after {
- right: 0;
- border-right-color: var(--border);
+}
+
+/* --------------------------------------------------------- supernova cutscene */
+.supernova-overlay {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ pointer-events: none;
+}
+
+.supernova-text {
+ font-size: clamp(20px, 4vw, 40px);
+ font-weight: 800;
+ letter-spacing: 0.03em;
+ color: #fff6d6;
+ text-shadow: 0 0 18px rgba(255, 224, 102, 0.85), 0 0 40px rgba(91, 140, 255, 0.6);
+ animation: supernova-fade 10s ease-in-out both;
+ text-align: center;
+ padding: 0 24px;
+}
+
+@keyframes supernova-fade {
+ 0% {
+ opacity: 0;
+ transform: scale(0.9);
+ }
+ 12% {
+ opacity: 1;
+ transform: scale(1);
+ }
+ 85% {
+ opacity: 1;
+ transform: scale(1);
+ }
+ 100% {
+ opacity: 0;
+ transform: scale(1.05);
}
}
diff --git a/src/App.tsx b/src/App.tsx
index 9ca8e8d..506d225 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,122 +1,32 @@
-import { useState } from 'react'
-import heroImg from './assets/hero.png'
-import reactLogo from './assets/react.svg'
-import viteLogo from './assets/vite.svg'
-import './App.css'
+import { useRef } from 'react';
+import { BossBanner } from './components/BossBanner';
+import { Hud } from './components/Hud';
+import { ShopPanel } from './components/ShopPanel';
+import { SupernovaOverlay } from './components/SupernovaOverlay';
+import { useGameEngine } from './hooks/useGameEngine';
+import './App.css';
function App() {
- const [count, setCount] = useState(0)
+ const canvasRef = useRef(null);
+ const { engine, snapshot } = useGameEngine(canvasRef);
return (
- <>
-
-
-
-
Get started
-
- Edit src/App.tsx and save to test HMR
-
-
-
-
-
-
-
-
-
-
-
Documentation
-
Your questions, answered
-
-
-
-
-
Connect with us
-
Join the Vite community
-
-
-
-
-
-
- >
- )
+
+
+
+ {snapshot && !snapshot.cutsceneActive && }
+ {snapshot?.bossActive && !snapshot.cutsceneActive && }
+ {snapshot?.cutsceneActive && }
+
+ {snapshot && (
+
engine.current?.buyUpgrade(key)}
+ onPrestige={() => engine.current?.prestige()}
+ />
+ )}
+
+ );
}
-export default App
+export default App;
diff --git a/src/assets/hero.png b/src/assets/hero.png
deleted file mode 100644
index 02251f4..0000000
Binary files a/src/assets/hero.png and /dev/null differ
diff --git a/src/assets/react.svg b/src/assets/react.svg
deleted file mode 100644
index 6c87de9..0000000
--- a/src/assets/react.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/src/assets/vite.svg b/src/assets/vite.svg
deleted file mode 100644
index 5101b67..0000000
--- a/src/assets/vite.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/src/components/BossBanner.tsx b/src/components/BossBanner.tsx
new file mode 100644
index 0000000..9e968ca
--- /dev/null
+++ b/src/components/BossBanner.tsx
@@ -0,0 +1,18 @@
+import { formatNumber } from '../game/format';
+import type { GameSnapshot } from '../game/types';
+
+export function BossBanner({ snapshot }: { snapshot: GameSnapshot }) {
+ const pct = snapshot.bossMaxHp > 0 ? (snapshot.bossHp / snapshot.bossMaxHp) * 100 : 0;
+ return (
+
+
Мега-Пират
+
+
+
+ {formatNumber(snapshot.bossHp)} / {formatNumber(snapshot.bossMaxHp)}
+
+
+
Тап по экрану — движение · Тап по боссу — доп. удар мечом
+
+ );
+}
diff --git a/src/components/Hud.tsx b/src/components/Hud.tsx
new file mode 100644
index 0000000..fcc94a8
--- /dev/null
+++ b/src/components/Hud.tsx
@@ -0,0 +1,36 @@
+import { formatNumber } from '../game/format';
+import type { GameSnapshot } from '../game/types';
+
+export function Hud({ snapshot }: { snapshot: GameSnapshot }) {
+ const progressPct = Math.min(100, (snapshot.crystalsInZone / snapshot.crystalsRequired) * 100);
+ return (
+
+
+
+ Очки
+ {formatNumber(snapshot.score)}
+
+
+
Зона {snapshot.zone}
+ {!snapshot.bossActive ? (
+
+
+
+ {snapshot.crystalsInZone}/{snapshot.crystalsRequired} кристаллов
+
+
+ ) : (
+
+ Битва с боссом
+
+ )}
+
+
+ Тёмная Материя
+ {formatNumber(snapshot.darkMatter)}
+
+
+
Тап по экрану — полёт · уворачивайтесь от астероидов
+
+ );
+}
diff --git a/src/components/ShopPanel.tsx b/src/components/ShopPanel.tsx
new file mode 100644
index 0000000..d4a4b28
--- /dev/null
+++ b/src/components/ShopPanel.tsx
@@ -0,0 +1,61 @@
+import { UPGRADES } from '../game/constants';
+import { formatNumber } from '../game/format';
+import type { GameSnapshot, UpgradeKey } from '../game/types';
+
+const KEYS: UpgradeKey[] = ['crystal', 'speed', 'damage', 'droid'];
+
+export function ShopPanel({
+ snapshot,
+ onBuy,
+ onPrestige,
+}: {
+ snapshot: GameSnapshot;
+ onBuy: (key: UpgradeKey) => void;
+ onPrestige: () => void;
+}) {
+ const locked = snapshot.cutsceneActive;
+ return (
+
+ {KEYS.map((key) => {
+ const info = UPGRADES[key];
+ const level = snapshot.levels[key];
+ const cost = snapshot.costs[key];
+ const afford = snapshot.canAfford[key] && !locked;
+ return (
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/components/SupernovaOverlay.tsx b/src/components/SupernovaOverlay.tsx
new file mode 100644
index 0000000..55f8b12
--- /dev/null
+++ b/src/components/SupernovaOverlay.tsx
@@ -0,0 +1,7 @@
+export function SupernovaOverlay() {
+ return (
+
+
Перелёт в новую галактику!
+
+ );
+}
diff --git a/src/game/GameEngine.ts b/src/game/GameEngine.ts
new file mode 100644
index 0000000..3584c8f
--- /dev/null
+++ b/src/game/GameEngine.ts
@@ -0,0 +1,1223 @@
+import { WORLD, UPGRADES, DROID_TIERS, type DroidTierConfig } from './constants';
+import { formatNumber } from './format';
+import { makeJitter, mulberry32 } from './random';
+import type {
+ AsteroidEntity,
+ CrystalEntity,
+ DroidEntity,
+ EnemyEntity,
+ FloatingText,
+ GameSnapshot,
+ GoldenAsteroidEntity,
+ Particle,
+ UpgradeKey,
+} from './types';
+
+const SAVE_KEY = 'space-knight-save-v2';
+
+interface SaveData {
+ score: number;
+ zone: number;
+ crystalsInZone: number;
+ darkMatter: number;
+ levels: Record;
+}
+
+let idCounter = 1;
+const nextId = () => idCounter++;
+
+function clamp(v: number, min: number, max: number): number {
+ return Math.min(max, Math.max(min, v));
+}
+
+interface KnightState {
+ x: number;
+ y: number;
+ facing: number;
+ attacking: boolean;
+ thrusting: boolean;
+ hitFlash: number;
+ locked: boolean;
+ attackTimer: number;
+}
+
+export class GameEngine {
+ private canvas: HTMLCanvasElement;
+ private ctx: CanvasRenderingContext2D;
+ private width = 960;
+ private height = 540;
+ private raf = 0;
+ private lastTime = 0;
+ private running = false;
+ private time = 0;
+
+ private listeners = new Set<(s: GameSnapshot) => void>();
+ private snapshotTimer = 0;
+ private saveTimer = 0;
+
+ score = 0;
+ zone = 1;
+ crystalsInZone = 0;
+ darkMatter = 0;
+ levels: Record = { crystal: 1, speed: 1, damage: 1, droid: 1 };
+
+ private crystals: CrystalEntity[] = [];
+ private pirate: EnemyEntity | null = null;
+ private boss: EnemyEntity | null = null;
+ private droids: DroidEntity[] = [];
+ private asteroids: AsteroidEntity[] = [];
+ private goldenAsteroid: GoldenAsteroidEntity | null = null;
+ private particles: Particle[] = [];
+ private floatingTexts: FloatingText[] = [];
+
+ private crystalSpawnTimer = 0;
+ private pirateSpawnTimer = 0;
+ private asteroidSpawnTimer = 0;
+ private goldenSpawnTimer = 0;
+ private trailTimer = 0;
+
+ private moveTarget: { x: number; y: number } | null = null;
+
+ private cutscene = { active: false, timer: 0 };
+
+ private knight: KnightState = {
+ x: 0,
+ y: 0,
+ facing: -Math.PI / 2,
+ attacking: false,
+ thrusting: false,
+ hitFlash: 0,
+ locked: false,
+ attackTimer: 0,
+ };
+
+ private stars: { x: number; y: number; r: number; a: number }[] = [];
+
+ private onPointerDown = (e: PointerEvent) => this.handlePointerDown(e);
+ private onResize = () => this.resize();
+ private resizeObserver?: ResizeObserver;
+ private loopBound = (t: number) => this.loop(t);
+
+ constructor(canvas: HTMLCanvasElement) {
+ this.canvas = canvas;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) throw new Error('Canvas 2D context unavailable');
+ this.ctx = ctx;
+ this.load();
+ this.resize();
+ this.knight.x = this.width / 2;
+ this.knight.y = this.height / 2;
+ this.initStars();
+ this.rebuildDroids();
+ if (this.zone % 10 === 0) this.startBoss();
+ }
+
+ // ---------------------------------------------------------------- lifecycle
+ start() {
+ if (this.running) return;
+ this.running = true;
+ this.canvas.addEventListener('pointerdown', this.onPointerDown);
+ window.addEventListener('resize', this.onResize);
+ if (typeof ResizeObserver !== 'undefined') {
+ this.resizeObserver = new ResizeObserver(() => this.resize());
+ this.resizeObserver.observe(this.canvas.parentElement ?? this.canvas);
+ }
+ this.lastTime = performance.now();
+ this.raf = requestAnimationFrame(this.loopBound);
+ }
+
+ stop() {
+ this.running = false;
+ cancelAnimationFrame(this.raf);
+ this.canvas.removeEventListener('pointerdown', this.onPointerDown);
+ window.removeEventListener('resize', this.onResize);
+ this.resizeObserver?.disconnect();
+ this.save();
+ }
+
+ subscribe(cb: (s: GameSnapshot) => void) {
+ this.listeners.add(cb);
+ cb(this.getSnapshot());
+ return () => {
+ this.listeners.delete(cb);
+ };
+ }
+
+ private notify() {
+ const snap = this.getSnapshot();
+ this.listeners.forEach((cb) => cb(snap));
+ }
+
+ private resize() {
+ const parent = this.canvas.parentElement;
+ const rect = parent ? parent.getBoundingClientRect() : this.canvas.getBoundingClientRect();
+ const dpr = window.devicePixelRatio || 1;
+ this.width = Math.max(320, Math.floor(rect.width));
+ this.height = Math.max(240, Math.floor(rect.height));
+ this.canvas.width = Math.floor(this.width * dpr);
+ this.canvas.height = Math.floor(this.height * dpr);
+ this.canvas.style.width = `${this.width}px`;
+ this.canvas.style.height = `${this.height}px`;
+ this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ }
+
+ private initStars() {
+ const rand = mulberry32(1337);
+ this.stars = Array.from({ length: 110 }, () => ({
+ x: rand(),
+ y: rand(),
+ r: rand() * 1.6 + 0.4,
+ a: rand() * 0.6 + 0.25,
+ }));
+ }
+
+ // ------------------------------------------------------------------ input
+ private handlePointerDown(e: PointerEvent) {
+ if (this.cutscene.active) return;
+ const rect = this.canvas.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const y = e.clientY - rect.top;
+
+ if (this.goldenAsteroid) {
+ const g = this.goldenAsteroid;
+ if (Math.hypot(x - g.x, y - g.y) <= g.radius + 14) {
+ this.catchGoldenAsteroid(g);
+ }
+ }
+
+ if (this.boss) {
+ const dist = Math.hypot(x - this.boss.x, y - this.boss.y);
+ if (dist <= this.boss.radius + 12) {
+ this.dealDamageTo(this.boss, this.knightDamage() * WORLD.MANUAL_CLICK_MULT);
+ this.knight.hitFlash = 1;
+ }
+ }
+
+ if (!this.knight.locked) {
+ this.moveTarget = { x: clamp(x, 0, this.width), y: clamp(y, 0, this.height) };
+ }
+ }
+
+ // --------------------------------------------------------------- economy
+ private darkMult(): number {
+ return 1 + this.darkMatter;
+ }
+
+ private knightDamage(): number {
+ return WORLD.BASE_KNIGHT_DAMAGE * (1 + (this.levels.damage - 1) * UPGRADES.damage.bonus) * this.darkMult();
+ }
+
+ private knightSpeed(): number {
+ return WORLD.BASE_KNIGHT_SPEED * (1 + (this.levels.speed - 1) * UPGRADES.speed.bonus);
+ }
+
+ private crystalValue(): number {
+ return (
+ WORLD.BASE_CRYSTAL_VALUE *
+ Math.pow(WORLD.CRYSTAL_VALUE_ZONE_MULT, this.zone - 1) *
+ (1 + (this.levels.crystal - 1) * UPGRADES.crystal.bonus) *
+ this.darkMult()
+ );
+ }
+
+ private pirateHp(): number {
+ return WORLD.BASE_PIRATE_HP * Math.pow(WORLD.PIRATE_HP_ZONE_MULT, this.zone - 1);
+ }
+
+ private cost(key: UpgradeKey): number {
+ const u = UPGRADES[key];
+ return Math.floor(u.baseCost * Math.pow(u.growth, this.levels[key] - 1));
+ }
+
+ buyUpgrade(key: UpgradeKey): boolean {
+ if (this.cutscene.active) return false;
+ const cost = this.cost(key);
+ if (this.score < cost) return false;
+ this.score -= cost;
+ this.levels[key] += 1;
+ if (key === 'crystal') {
+ const ang = this.knight.facing;
+ this.spawnCrystal(this.knight.x + Math.cos(ang) * 46, this.knight.y + Math.sin(ang) * 46);
+ }
+ if (key === 'droid') this.rebuildDroids();
+ this.save();
+ this.notify();
+ return true;
+ }
+
+ prestige(): boolean {
+ if (this.cutscene.active || this.zone < WORLD.PRESTIGE_MIN_ZONE) return false;
+ this.cutscene.active = true;
+ this.cutscene.timer = 0;
+ this.moveTarget = null;
+ this.notify();
+ return true;
+ }
+
+ private applyPrestigeReset() {
+ this.darkMatter += WORLD.SUPERNOVA_DARK_MATTER_GAIN;
+ this.score = 0;
+ this.zone = 1;
+ this.crystalsInZone = 0;
+ this.levels = { crystal: 1, speed: 1, damage: 1, droid: 1 };
+ this.crystals = [];
+ this.pirate = null;
+ this.boss = null;
+ this.asteroids = [];
+ this.goldenAsteroid = null;
+ this.particles = [];
+ this.floatingTexts = [];
+ this.pirateSpawnTimer = 0;
+ this.crystalSpawnTimer = 0;
+ this.asteroidSpawnTimer = 0;
+ this.goldenSpawnTimer = 0;
+ this.knight.locked = false;
+ this.knight.x = this.width / 2;
+ this.knight.y = this.height / 2;
+ this.moveTarget = null;
+ this.rebuildDroids();
+ this.save();
+ }
+
+ private rebuildDroids() {
+ const count = Math.max(0, this.levels.droid - 1);
+ this.droids = Array.from({ length: count }, (_, i) => ({
+ id: nextId(),
+ angleOffset: (Math.PI * 2 * i) / Math.max(count, 1),
+ cooldown: (WORLD.DROID_INTERVAL / Math.max(count, 1)) * i,
+ laserTimer: 0,
+ }));
+ }
+
+ private droidTierConfig(): DroidTierConfig {
+ const lvl = this.levels.droid;
+ let cfg = DROID_TIERS[0];
+ for (const t of DROID_TIERS) if (lvl >= t.minLevel) cfg = t;
+ return cfg;
+ }
+
+ // --------------------------------------------------------------- spawning
+ private spawnCrystal(x?: number, y?: number) {
+ const margin = 40;
+ const cx = x ?? margin + Math.random() * (this.width - margin * 2);
+ const cy = y ?? margin + Math.random() * (this.height - margin * 2);
+ const id = nextId();
+ this.crystals.push({
+ id,
+ x: clamp(cx, margin, this.width - margin),
+ y: clamp(cy, margin, this.height - margin),
+ value: this.crystalValue(),
+ radius: 13 + Math.random() * 4,
+ rotation: Math.random() * Math.PI * 2,
+ spin: (Math.random() > 0.5 ? 1 : -1) * (0.5 + Math.random() * 0.6),
+ jitter: makeJitter(id, 6, 0.14),
+ });
+ }
+
+ private spawnPirate() {
+ const edge = Math.floor(Math.random() * 4);
+ const m = 30;
+ let x = 0;
+ let y = 0;
+ if (edge === 0) {
+ x = -m;
+ y = Math.random() * this.height;
+ } else if (edge === 1) {
+ x = this.width + m;
+ y = Math.random() * this.height;
+ } else if (edge === 2) {
+ x = Math.random() * this.width;
+ y = -m;
+ } else {
+ x = Math.random() * this.width;
+ y = this.height + m;
+ }
+ const id = nextId();
+ const hp = this.pirateHp();
+ this.pirate = {
+ id,
+ x,
+ y,
+ hp,
+ maxHp: hp,
+ speed: WORLD.BASE_PIRATE_SPEED,
+ radius: 22,
+ isBoss: false,
+ state: 'chase',
+ wanderAngle: Math.random() * Math.PI * 2,
+ jitter: makeJitter(id, 8, 0.22),
+ hitFlash: 0,
+ };
+ }
+
+ private startBoss() {
+ const id = nextId();
+ const hp = this.pirateHp() * WORLD.BOSS_HP_MULT;
+ this.boss = {
+ id,
+ x: this.width / 2,
+ y: Math.max(80, this.height * 0.3),
+ hp,
+ maxHp: hp,
+ speed: WORLD.BASE_BOSS_SPEED,
+ radius: 48,
+ isBoss: true,
+ state: 'chase',
+ wanderAngle: 0,
+ jitter: makeJitter(id, 10, 0.2),
+ hitFlash: 0,
+ };
+ this.knight.locked = false;
+ this.knight.attackTimer = 0;
+ this.crystals = [];
+ this.pirate = null;
+ this.moveTarget = null;
+ }
+
+ private spawnAsteroid() {
+ const radius = WORLD.ASTEROID_MIN_RADIUS + Math.random() * (WORLD.ASTEROID_MAX_RADIUS - WORLD.ASTEROID_MIN_RADIUS);
+ const id = nextId();
+ this.asteroids.push({
+ id,
+ x: Math.random() * this.width,
+ y: -radius - 10,
+ vx: (Math.random() - 0.5) * 30,
+ speed: clamp(210 - radius * 2.2, 70, 210) + (Math.random() - 0.5) * 20,
+ radius,
+ rotation: Math.random() * Math.PI * 2,
+ spin: (Math.random() > 0.5 ? 1 : -1) * (0.3 + Math.random() * 0.6),
+ jitter: makeJitter(id, 8, 0.22),
+ });
+ }
+
+ private spawnGoldenAsteroid() {
+ const fromLeft = Math.random() > 0.5;
+ const y = this.height * (0.15 + Math.random() * 0.4);
+ const id = nextId();
+ const travel = this.width + 160;
+ const duration = 7 + Math.random() * 2;
+ this.goldenAsteroid = {
+ id,
+ x: fromLeft ? -80 : this.width + 80,
+ y,
+ vx: (fromLeft ? 1 : -1) * (travel / duration),
+ vy: (Math.random() - 0.5) * 12,
+ radius: WORLD.GOLDEN_ASTEROID_RADIUS,
+ rotation: 0,
+ jitter: makeJitter(id, 8, 0.16),
+ };
+ }
+
+ private catchGoldenAsteroid(g: GoldenAsteroidEntity) {
+ const reward = this.crystalValue() * WORLD.GOLDEN_ASTEROID_REWARD_MULT;
+ this.score += reward;
+ this.spawnBurst(g.x, g.y, '255,224,102', 18, 150);
+ this.spawnFloatingText(g.x, g.y - 10, `+${formatNumber(reward)}`, '#ffe066');
+ this.goldenAsteroid = null;
+ }
+
+ // ---------------------------------------------------------------- effects
+ private spawnTrailParticle() {
+ const k = this.knight;
+ this.particles.push({
+ x: k.x - Math.cos(k.facing) * 12 + (Math.random() - 0.5) * 6,
+ y: k.y - Math.sin(k.facing) * 12 + (Math.random() - 0.5) * 6,
+ vx: (Math.random() - 0.5) * 15,
+ vy: (Math.random() - 0.5) * 15,
+ life: 0.35,
+ maxLife: 0.35,
+ size: 2.5 + Math.random() * 2,
+ color: '127,255,224',
+ });
+ }
+
+ private spawnBurst(x: number, y: number, color: string, count: number, speed: number) {
+ for (let i = 0; i < count; i++) {
+ const a = Math.random() * Math.PI * 2;
+ const s = speed * (0.4 + Math.random() * 0.8);
+ this.particles.push({
+ x,
+ y,
+ vx: Math.cos(a) * s,
+ vy: Math.sin(a) * s,
+ life: 0.5,
+ maxLife: 0.5,
+ size: 2 + Math.random() * 2.5,
+ color,
+ });
+ }
+ }
+
+ private spawnFloatingText(x: number, y: number, text: string, color: string) {
+ this.floatingTexts.push({ x, y, vy: -38, life: 0.9, maxLife: 0.9, text, color });
+ }
+
+ private updateParticles(dt: number) {
+ this.particles = this.particles.filter((p) => {
+ p.x += p.vx * dt;
+ p.y += p.vy * dt;
+ p.vx *= 0.94;
+ p.vy *= 0.94;
+ p.life -= dt;
+ return p.life > 0;
+ });
+ this.floatingTexts = this.floatingTexts.filter((f) => {
+ f.y += f.vy * dt;
+ f.life -= dt;
+ return f.life > 0;
+ });
+ }
+
+ // ----------------------------------------------------------------- update
+ private update(dt: number) {
+ this.time += dt;
+ this.saveTimer += dt;
+ if (this.saveTimer > 5) {
+ this.saveTimer = 0;
+ this.save();
+ }
+
+ if (this.cutscene.active) {
+ this.updateCutscene(dt);
+ } else {
+ this.updateAsteroids(dt);
+ if (this.boss) this.updateBossMode(dt);
+ else this.updateNormalMode(dt);
+ this.updateDroids(dt);
+ this.checkDeaths();
+ for (const c of this.crystals) c.rotation += c.spin * dt;
+ }
+
+ 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);
+ if (this.knight.hitFlash > 0) this.knight.hitFlash = Math.max(0, this.knight.hitFlash - dt * 3);
+
+ this.snapshotTimer += dt;
+ if (this.snapshotTimer > 0.12) {
+ this.snapshotTimer = 0;
+ this.notify();
+ }
+ }
+
+ private updateCutscene(dt: number) {
+ this.cutscene.timer += dt;
+ if (this.cutscene.timer >= WORLD.SUPERNOVA_DURATION) {
+ this.applyPrestigeReset();
+ this.cutscene.active = false;
+ this.cutscene.timer = 0;
+ }
+ }
+
+ private tickAttack(target: EnemyEntity, dt: number, faceTarget: boolean) {
+ this.knight.attackTimer += dt;
+ if (faceTarget) this.knight.facing = Math.atan2(target.y - this.knight.y, target.x - this.knight.x);
+ if (this.knight.attackTimer >= WORLD.ATTACK_INTERVAL) {
+ this.knight.attackTimer = 0;
+ this.dealDamageTo(target, this.knightDamage());
+ this.knight.attacking = true;
+ } else if (this.knight.attackTimer > 0.15) {
+ this.knight.attacking = false;
+ }
+ }
+
+ private dealDamageTo(e: EnemyEntity, amount: number) {
+ e.hp -= amount;
+ e.hitFlash = 1;
+ }
+
+ private clampKnight() {
+ this.knight.x = clamp(this.knight.x, 22, this.width - 22);
+ this.knight.y = clamp(this.knight.y, 22, this.height - 22);
+ }
+
+ private applyMoveTarget(dt: number) {
+ if (!this.moveTarget) {
+ this.knight.thrusting = false;
+ return;
+ }
+ const dx = this.moveTarget.x - this.knight.x;
+ const dy = this.moveTarget.y - this.knight.y;
+ const dist = Math.hypot(dx, dy);
+ if (dist <= WORLD.MOVE_ARRIVE_EPS) {
+ this.knight.x = this.moveTarget.x;
+ this.knight.y = this.moveTarget.y;
+ this.moveTarget = null;
+ this.knight.thrusting = false;
+ return;
+ }
+ this.knight.thrusting = true;
+ this.knight.facing = Math.atan2(dy, dx);
+ const step = this.knightSpeed() * dt;
+ if (step >= dist) {
+ this.knight.x = this.moveTarget.x;
+ this.knight.y = this.moveTarget.y;
+ this.moveTarget = null;
+ this.knight.thrusting = false;
+ } else {
+ this.knight.x += (dx / dist) * step;
+ this.knight.y += (dy / dist) * step;
+ this.trailTimer += dt;
+ if (this.trailTimer >= WORLD.TRAIL_INTERVAL) {
+ this.trailTimer = 0;
+ this.spawnTrailParticle();
+ }
+ }
+ }
+
+ private collectNearbyCrystals() {
+ if (this.crystals.length === 0) return;
+ const collected: CrystalEntity[] = [];
+ this.crystals = this.crystals.filter((c) => {
+ const d = Math.hypot(c.x - this.knight.x, c.y - this.knight.y);
+ if (d <= WORLD.COLLECT_RANGE) {
+ collected.push(c);
+ return false;
+ }
+ return true;
+ });
+ for (const c of collected) {
+ this.score += c.value;
+ this.crystalsInZone += 1;
+ this.spawnBurst(c.x, c.y, '142,247,255', 12, 110);
+ this.spawnFloatingText(c.x, c.y - 12, `+${formatNumber(c.value)}`, '#8ef7ff');
+ }
+ if (this.crystalsInZone >= WORLD.CRYSTALS_PER_ZONE) this.advanceZone();
+ }
+
+ private advanceZone() {
+ this.zone += 1;
+ this.crystalsInZone = 0;
+ this.crystals = [];
+ this.pirate = null;
+ this.pirateSpawnTimer = 0;
+ this.crystalSpawnTimer = 0;
+ if (this.zone % 10 === 0) this.startBoss();
+ this.save();
+ }
+
+ private updateNormalMode(dt: number) {
+ if (this.crystals.length < WORLD.MAX_CRYSTALS_ON_SCREEN) {
+ this.crystalSpawnTimer += dt;
+ if (this.crystalSpawnTimer >= WORLD.CRYSTAL_SPAWN_INTERVAL) {
+ this.crystalSpawnTimer = 0;
+ this.spawnCrystal();
+ }
+ }
+
+ if (!this.pirate) {
+ this.pirateSpawnTimer += dt;
+ if (this.pirateSpawnTimer >= WORLD.PIRATE_SPAWN_INTERVAL) {
+ this.pirateSpawnTimer = 0;
+ this.spawnPirate();
+ }
+ }
+
+ if (this.pirate && this.pirate.state === 'chase') {
+ const p = this.pirate;
+ const dx = this.knight.x - p.x;
+ const dy = this.knight.y - p.y;
+ const dist = Math.hypot(dx, dy);
+ if (dist > WORLD.CONTACT_RANGE) {
+ p.x += (dx / dist) * p.speed * dt;
+ p.y += (dy / dist) * p.speed * dt;
+ } else {
+ p.state = 'fight';
+ this.knight.locked = true;
+ this.knight.attackTimer = 0;
+ this.moveTarget = null;
+ }
+ }
+
+ if (this.knight.locked && this.pirate) {
+ this.tickAttack(this.pirate, dt, true);
+ this.knight.thrusting = false;
+ } else {
+ this.applyMoveTarget(dt);
+ this.collectNearbyCrystals();
+ }
+
+ this.clampKnight();
+ }
+
+ private updateBossMode(dt: number) {
+ const boss = this.boss;
+ if (!boss) return;
+
+ this.applyMoveTarget(dt);
+ this.clampKnight();
+
+ boss.wanderAngle += dt * 0.6;
+ const targetX = clamp(this.knight.x + Math.cos(boss.wanderAngle) * 150, boss.radius, this.width - boss.radius);
+ const targetY = clamp(this.knight.y + Math.sin(boss.wanderAngle) * 150, boss.radius, this.height - boss.radius);
+ const bdx = targetX - boss.x;
+ const bdy = targetY - boss.y;
+ const bdist = Math.hypot(bdx, bdy);
+ if (bdist > 2) {
+ boss.x += (bdx / bdist) * boss.speed * dt;
+ boss.y += (bdy / bdist) * boss.speed * dt;
+ }
+ boss.x = clamp(boss.x, boss.radius, this.width - boss.radius);
+ boss.y = clamp(boss.y, boss.radius, this.height - boss.radius);
+
+ const dist = Math.hypot(this.knight.x - boss.x, this.knight.y - boss.y);
+ if (dist <= WORLD.CONTACT_RANGE + boss.radius * 0.5) {
+ this.tickAttack(boss, dt, false);
+ } else if (this.knight.attackTimer > 0.15) {
+ this.knight.attacking = false;
+ }
+ }
+
+ private updateAsteroids(dt: number) {
+ this.asteroidSpawnTimer += dt;
+ if (this.asteroidSpawnTimer >= WORLD.ASTEROID_SPAWN_INTERVAL) {
+ this.asteroidSpawnTimer = 0;
+ this.spawnAsteroid();
+ }
+
+ this.asteroids = this.asteroids.filter((a) => {
+ a.x += a.vx * dt;
+ a.y += a.speed * dt;
+ a.rotation += a.spin * dt;
+ if (a.y - a.radius > this.height + 20) return false;
+ const d = Math.hypot(a.x - this.knight.x, a.y - this.knight.y);
+ if (d <= a.radius + WORLD.KNIGHT_HIT_RADIUS) {
+ this.applyAsteroidHit();
+ return false;
+ }
+ return true;
+ });
+
+ this.goldenSpawnTimer += dt;
+ if (!this.goldenAsteroid && this.goldenSpawnTimer >= WORLD.GOLDEN_ASTEROID_INTERVAL) {
+ this.goldenSpawnTimer = 0;
+ this.spawnGoldenAsteroid();
+ }
+ if (this.goldenAsteroid) {
+ const g = this.goldenAsteroid;
+ g.x += g.vx * dt;
+ g.y += g.vy * dt;
+ g.rotation += 1.2 * dt;
+ if (g.x < -80 || g.x > this.width + 80 || g.y < -80 || g.y > this.height + 80) {
+ this.goldenAsteroid = null;
+ }
+ }
+ }
+
+ private applyAsteroidHit() {
+ const loss = this.score * WORLD.ASTEROID_SCORE_LOSS_RATIO;
+ if (loss > 0) {
+ this.score = Math.max(0, this.score - loss);
+ this.spawnFloatingText(this.knight.x, this.knight.y - 24, `-${formatNumber(loss)}`, '#ff5a7a');
+ }
+ this.knight.hitFlash = 1;
+ this.spawnBurst(this.knight.x, this.knight.y, '255,90,122', 10, 100);
+ }
+
+ private updateDroids(dt: number) {
+ const target = this.boss ?? this.pirate;
+ for (const d of this.droids) {
+ if (d.laserTimer > 0) d.laserTimer = Math.max(0, d.laserTimer - dt * 3);
+ if (!target) continue;
+ d.cooldown += dt;
+ if (d.cooldown >= WORLD.DROID_INTERVAL) {
+ d.cooldown = 0;
+ this.dealDamageTo(target, this.knightDamage() * WORLD.DROID_DAMAGE_RATIO);
+ d.laserTimer = 1;
+ }
+ }
+ }
+
+ private checkDeaths() {
+ if (this.pirate && this.pirate.hp <= 0) {
+ this.pirate = null;
+ this.knight.locked = false;
+ }
+ if (this.boss && this.boss.hp <= 0) {
+ this.boss = null;
+ this.zone += 1;
+ this.crystalsInZone = 0;
+ this.pirateSpawnTimer = 0;
+ this.crystalSpawnTimer = 0;
+ if (this.zone % 10 === 0) this.startBoss();
+ this.save();
+ }
+ }
+
+ // ----------------------------------------------------------------- render
+ private polygonPath(cx: number, cy: number, radius: number, sides: number, rotation: number, jitter?: number[]) {
+ const ctx = this.ctx;
+ ctx.beginPath();
+ for (let i = 0; i < sides; i++) {
+ const a = rotation + (Math.PI * 2 * i) / sides;
+ const r = radius * (jitter?.[i] ?? 1);
+ const x = cx + Math.cos(a) * r;
+ const y = cy + Math.sin(a) * r;
+ if (i === 0) ctx.moveTo(x, y);
+ else ctx.lineTo(x, y);
+ }
+ ctx.closePath();
+ }
+
+ private starPath(cx: number, cy: number, outerR: number, innerR: number, points: number, rotation: number) {
+ const ctx = this.ctx;
+ ctx.beginPath();
+ for (let i = 0; i < points * 2; i++) {
+ const r = i % 2 === 0 ? outerR : innerR;
+ const a = rotation + (Math.PI * i) / points;
+ const x = cx + Math.cos(a) * r;
+ const y = cy + Math.sin(a) * r;
+ if (i === 0) ctx.moveTo(x, y);
+ else ctx.lineTo(x, y);
+ }
+ ctx.closePath();
+ }
+
+ private drawBackground() {
+ const ctx = this.ctx;
+ const g = ctx.createLinearGradient(0, 0, 0, this.height);
+ g.addColorStop(0, '#0a0e24');
+ g.addColorStop(1, '#171c40');
+ ctx.fillStyle = g;
+ ctx.fillRect(0, 0, this.width, this.height);
+
+ ctx.fillStyle = '#ffffff';
+ for (const s of this.stars) {
+ ctx.globalAlpha = s.a;
+ ctx.beginPath();
+ ctx.arc(s.x * this.width, s.y * this.height, s.r, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.globalAlpha = 1;
+ }
+
+ private drawCrystal(c: CrystalEntity) {
+ const ctx = this.ctx;
+ ctx.save();
+ ctx.translate(c.x, c.y);
+ ctx.rotate(c.rotation);
+ const grad = ctx.createLinearGradient(-c.radius, -c.radius, c.radius, c.radius);
+ grad.addColorStop(0, '#8ef7ff');
+ grad.addColorStop(0.5, '#5b8cff');
+ grad.addColorStop(1, '#9b5bff');
+ this.polygonPath(0, 0, c.radius, 6, 0, c.jitter);
+ ctx.fillStyle = grad;
+ ctx.shadowColor = '#7fd9ff';
+ ctx.shadowBlur = 14;
+ ctx.fill();
+ ctx.shadowBlur = 0;
+ ctx.strokeStyle = 'rgba(255,255,255,0.6)';
+ ctx.lineWidth = 1.5;
+ ctx.stroke();
+ ctx.strokeStyle = 'rgba(255,255,255,0.35)';
+ for (let i = 0; i < 3; i++) {
+ const a = (Math.PI * 2 * i) / 3;
+ ctx.beginPath();
+ ctx.moveTo(0, 0);
+ ctx.lineTo(Math.cos(a) * c.radius * 0.8, Math.sin(a) * c.radius * 0.8);
+ ctx.stroke();
+ }
+ ctx.restore();
+ }
+
+ private drawAsteroids() {
+ const ctx = this.ctx;
+ for (const a of this.asteroids) {
+ ctx.save();
+ ctx.translate(a.x, a.y);
+ ctx.rotate(a.rotation);
+ this.polygonPath(0, 0, a.radius, 8, 0, a.jitter);
+ ctx.fillStyle = '#8890a6';
+ ctx.fill();
+ ctx.strokeStyle = '#4d5266';
+ ctx.lineWidth = 1.5;
+ ctx.stroke();
+ ctx.restore();
+ }
+ }
+
+ private drawGoldenAsteroid() {
+ const g = this.goldenAsteroid;
+ if (!g) return;
+ const ctx = this.ctx;
+ ctx.save();
+ ctx.translate(g.x, g.y);
+ ctx.rotate(g.rotation);
+ const pulse = 1 + Math.sin(this.time * 5) * 0.06;
+ this.polygonPath(0, 0, g.radius * pulse, 8, 0, g.jitter);
+ const grad = ctx.createRadialGradient(0, 0, 2, 0, 0, g.radius);
+ grad.addColorStop(0, '#fff6d6');
+ grad.addColorStop(1, '#e8a91c');
+ ctx.fillStyle = grad;
+ ctx.shadowColor = '#ffe066';
+ ctx.shadowBlur = 18;
+ ctx.fill();
+ ctx.shadowBlur = 0;
+ ctx.strokeStyle = '#a86f0f';
+ ctx.lineWidth = 2;
+ ctx.stroke();
+ ctx.restore();
+ }
+
+ private drawHealthBar(x: number, y: number, w: number, h: number, ratio: number, color: string) {
+ const ctx = this.ctx;
+ const r = clamp(ratio, 0, 1);
+ ctx.fillStyle = 'rgba(0,0,0,0.5)';
+ ctx.fillRect(x - 2, y - 2, w + 4, h + 4);
+ ctx.fillStyle = 'rgba(255,255,255,0.15)';
+ ctx.fillRect(x, y, w, h);
+ ctx.fillStyle = color;
+ ctx.fillRect(x, y, w * r, h);
+ }
+
+ private drawEnemy(e: EnemyEntity) {
+ const ctx = this.ctx;
+ ctx.save();
+ ctx.translate(e.x, e.y);
+ const wob = Math.sin(this.time * 4 + e.id) * 0.06;
+ this.polygonPath(0, 0, e.radius, e.isBoss ? 10 : 7, wob, e.jitter);
+ ctx.fillStyle = e.hitFlash > 0 ? '#ffffff' : e.isBoss ? '#ff5a7a' : '#7bffb0';
+ ctx.fill();
+ ctx.lineWidth = 2;
+ ctx.strokeStyle = e.isBoss ? '#7a0f2b' : '#0f7a4a';
+ ctx.stroke();
+ ctx.fillStyle = '#0b1026';
+ const eyeOffset = e.radius * 0.32;
+ ctx.beginPath();
+ ctx.arc(-eyeOffset, -2, e.radius * 0.09, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.beginPath();
+ ctx.arc(eyeOffset, -2, e.radius * 0.09, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.restore();
+
+ const barW = e.isBoss ? 100 : 46;
+ const barY = e.y - e.radius - 16;
+ this.drawHealthBar(e.x - barW / 2, barY, barW, e.isBoss ? 10 : 6, e.hp / e.maxHp, e.isBoss ? '#ff5a7a' : '#7bffb0');
+ if (e.isBoss) {
+ ctx.fillStyle = '#ffdede';
+ ctx.font = 'bold 13px system-ui, sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillText('МЕГА-ПИРАТ', e.x, barY - 8);
+ }
+ }
+
+ private drawDroids() {
+ const ctx = this.ctx;
+ const target = this.boss ?? this.pirate;
+ const cfg = this.droidTierConfig();
+ for (const d of this.droids) {
+ const angle = this.time * 1.2 + d.angleOffset;
+ const x = this.knight.x + Math.cos(angle) * WORLD.DROID_ORBIT_RADIUS;
+ const y = this.knight.y + Math.sin(angle) * WORLD.DROID_ORBIT_RADIUS * 0.6 - 10;
+ ctx.save();
+ ctx.translate(x, y);
+ if (cfg.spikes > 0) this.starPath(0, 0, cfg.radius, cfg.radius * 0.5, cfg.spikes, this.time * 2);
+ else this.polygonPath(0, 0, cfg.radius, 6, this.time * 2);
+ ctx.fillStyle = cfg.color;
+ if (cfg.glow > 6) {
+ ctx.shadowColor = cfg.color;
+ ctx.shadowBlur = cfg.glow * 0.5;
+ }
+ ctx.fill();
+ ctx.shadowBlur = 0;
+ ctx.strokeStyle = cfg.stroke;
+ ctx.lineWidth = 1.5;
+ ctx.stroke();
+ ctx.restore();
+ if (d.laserTimer > 0 && target) {
+ ctx.save();
+ ctx.strokeStyle = `rgba(255,120,80,${d.laserTimer})`;
+ ctx.lineWidth = cfg.laserWidth;
+ if (cfg.glow > 6) {
+ ctx.shadowColor = '#ff7850';
+ ctx.shadowBlur = cfg.glow;
+ }
+ ctx.beginPath();
+ ctx.moveTo(x, y);
+ ctx.lineTo(target.x, target.y);
+ ctx.stroke();
+ ctx.shadowBlur = 0;
+ ctx.restore();
+ }
+ }
+ }
+
+ private drawParticles() {
+ const ctx = this.ctx;
+ for (const p of this.particles) {
+ const alpha = Math.max(0, p.life / p.maxLife);
+ ctx.fillStyle = `rgba(${p.color},${alpha})`;
+ ctx.beginPath();
+ ctx.arc(p.x, p.y, p.size * alpha + 0.5, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ }
+
+ private drawFloatingTexts() {
+ const ctx = this.ctx;
+ ctx.textAlign = 'center';
+ ctx.font = 'bold 13px system-ui, sans-serif';
+ for (const f of this.floatingTexts) {
+ const alpha = Math.max(0, f.life / f.maxLife);
+ ctx.globalAlpha = alpha;
+ ctx.fillStyle = f.color;
+ ctx.fillText(f.text, f.x, f.y);
+ }
+ ctx.globalAlpha = 1;
+ }
+
+ private drawKnight() {
+ const ctx = this.ctx;
+ const k = this.knight;
+ ctx.save();
+ ctx.translate(k.x, k.y);
+ ctx.rotate(k.facing + Math.PI / 2);
+
+ if (k.thrusting) {
+ ctx.beginPath();
+ ctx.moveTo(-6, 14);
+ ctx.lineTo(0, 22 + Math.random() * 6);
+ ctx.lineTo(6, 14);
+ ctx.closePath();
+ ctx.fillStyle = '#ff9a3c';
+ ctx.fill();
+ }
+
+ ctx.beginPath();
+ ctx.moveTo(0, -16);
+ ctx.lineTo(12, 10);
+ ctx.lineTo(0, 18);
+ ctx.lineTo(-12, 10);
+ ctx.closePath();
+ ctx.fillStyle = '#d7dbe6';
+ ctx.fill();
+ ctx.strokeStyle = '#8890a6';
+ 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.fill();
+
+ this.polygonPath(0, -18, 10, 7, 0);
+ ctx.fillStyle = '#eef2ff';
+ ctx.fill();
+ ctx.strokeStyle = '#8890a6';
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.ellipse(0, -18, 5, 4, 0, 0, Math.PI * 2);
+ ctx.fillStyle = '#173a5e';
+ ctx.fill();
+
+ const swordLen = k.attacking ? 34 : 22;
+ ctx.save();
+ ctx.rotate(k.attacking ? 0.9 : 0.3);
+ ctx.beginPath();
+ ctx.moveTo(10, 0);
+ ctx.lineTo(10 + swordLen, -4);
+ ctx.lineTo(10 + swordLen + 4, 0);
+ ctx.lineTo(10 + swordLen, 4);
+ ctx.closePath();
+ ctx.fillStyle = '#7fffe0';
+ ctx.shadowColor = '#7fffe0';
+ ctx.shadowBlur = k.attacking ? 16 : 6;
+ ctx.fill();
+ ctx.shadowBlur = 0;
+ ctx.restore();
+
+ ctx.restore();
+ }
+
+ private drawRocket(cx: number, cy: number, intensity: number) {
+ const ctx = this.ctx;
+ ctx.save();
+ ctx.translate(cx, cy);
+ const wobble = Math.sin(this.time * 6) * 2 * intensity;
+ ctx.rotate(wobble * 0.02);
+
+ const flameLen = 30 + intensity * 50 + Math.random() * 10;
+ ctx.beginPath();
+ ctx.moveTo(-10, 26);
+ ctx.lineTo(0, 26 + flameLen);
+ ctx.lineTo(10, 26);
+ ctx.closePath();
+ const flameGrad = ctx.createLinearGradient(0, 26, 0, 26 + flameLen);
+ flameGrad.addColorStop(0, '#fff3c4');
+ flameGrad.addColorStop(1, 'rgba(255,120,60,0)');
+ ctx.fillStyle = flameGrad;
+ ctx.fill();
+
+ ctx.beginPath();
+ ctx.moveTo(-10, 14);
+ ctx.lineTo(-24, 30);
+ ctx.lineTo(-8, 24);
+ ctx.closePath();
+ ctx.fillStyle = '#5b8cff';
+ ctx.fill();
+ ctx.beginPath();
+ ctx.moveTo(10, 14);
+ ctx.lineTo(24, 30);
+ ctx.lineTo(8, 24);
+ ctx.closePath();
+ ctx.fillStyle = '#5b8cff';
+ ctx.fill();
+
+ ctx.beginPath();
+ ctx.moveTo(0, -32);
+ ctx.lineTo(14, 10);
+ ctx.lineTo(14, 22);
+ ctx.lineTo(-14, 22);
+ ctx.lineTo(-14, 10);
+ ctx.closePath();
+ ctx.fillStyle = '#eef2ff';
+ ctx.fill();
+ ctx.strokeStyle = '#8890a6';
+ ctx.lineWidth = 1.5;
+ ctx.stroke();
+
+ ctx.beginPath();
+ ctx.arc(0, -4, 7, 0, Math.PI * 2);
+ ctx.fillStyle = '#173a5e';
+ ctx.fill();
+ ctx.strokeStyle = '#5bd0ff';
+ ctx.stroke();
+
+ ctx.restore();
+ }
+
+ private renderCutscene() {
+ const ctx = this.ctx;
+ const t = this.cutscene.timer;
+ const duration = WORLD.SUPERNOVA_DURATION;
+ let intensity = 1;
+ if (t < 1.2) intensity = t / 1.2;
+ else if (t > duration - 1.5) intensity = Math.max(0, (duration - t) / 1.5);
+
+ ctx.fillStyle = '#01030c';
+ ctx.fillRect(0, 0, this.width, this.height);
+
+ const cx = this.width / 2;
+ const cy = this.height / 2;
+ for (const s of this.stars) {
+ const sx = s.x * this.width;
+ const sy = s.y * this.height;
+ const dx = sx - cx;
+ const dy = sy - cy;
+ const dist = Math.hypot(dx, dy) || 1;
+ const ux = dx / dist;
+ const uy = dy / dist;
+ const len = 16 + intensity * 260 * (0.3 + s.r / 2);
+ ctx.globalAlpha = 0.35 + intensity * 0.5;
+ ctx.lineWidth = 1 + intensity * 1.5;
+ ctx.strokeStyle = '#ffffff';
+ ctx.beginPath();
+ ctx.moveTo(sx, sy);
+ ctx.lineTo(sx + ux * len, sy + uy * len);
+ ctx.stroke();
+ }
+ ctx.globalAlpha = 1;
+
+ this.drawRocket(cx, cy + this.height * 0.12, intensity);
+ }
+
+ private render() {
+ if (this.cutscene.active) {
+ this.renderCutscene();
+ return;
+ }
+ this.drawBackground();
+ for (const c of this.crystals) this.drawCrystal(c);
+ this.drawAsteroids();
+ this.drawGoldenAsteroid();
+ if (this.pirate) this.drawEnemy(this.pirate);
+ if (this.boss) this.drawEnemy(this.boss);
+ this.drawDroids();
+ this.drawParticles();
+ this.drawKnight();
+ this.drawFloatingTexts();
+ }
+
+ // ------------------------------------------------------------------ loop
+ private loop(t: number) {
+ if (!this.running) return;
+ let dt = (t - this.lastTime) / 1000;
+ this.lastTime = t;
+ dt = clamp(dt, 0, 0.05);
+ this.update(dt);
+ this.render();
+ this.raf = requestAnimationFrame(this.loopBound);
+ }
+
+ // --------------------------------------------------------------- snapshot
+ getSnapshot(): GameSnapshot {
+ const bossActive = !!this.boss;
+ return {
+ score: this.score,
+ zone: this.zone,
+ crystalsInZone: this.crystalsInZone,
+ crystalsRequired: WORLD.CRYSTALS_PER_ZONE,
+ darkMatter: this.darkMatter,
+ bossActive,
+ bossHp: this.boss?.hp ?? 0,
+ bossMaxHp: this.boss?.maxHp ?? 0,
+ pirateActive: !!this.pirate,
+ pirateHp: this.pirate?.hp ?? 0,
+ pirateMaxHp: this.pirate?.maxHp ?? 0,
+ levels: { ...this.levels },
+ costs: {
+ crystal: this.cost('crystal'),
+ speed: this.cost('speed'),
+ damage: this.cost('damage'),
+ droid: this.cost('droid'),
+ },
+ canAfford: {
+ crystal: this.score >= this.cost('crystal'),
+ speed: this.score >= this.cost('speed'),
+ damage: this.score >= this.cost('damage'),
+ droid: this.score >= this.cost('droid'),
+ },
+ droidCount: this.droids.length,
+ canPrestige: this.zone >= WORLD.PRESTIGE_MIN_ZONE,
+ prestigeGain: WORLD.SUPERNOVA_DARK_MATTER_GAIN,
+ cutsceneActive: this.cutscene.active,
+ };
+ }
+
+ // ----------------------------------------------------------------- save
+ private save() {
+ try {
+ const data: SaveData = {
+ score: this.score,
+ zone: this.zone,
+ crystalsInZone: this.crystalsInZone,
+ darkMatter: this.darkMatter,
+ levels: { ...this.levels },
+ };
+ localStorage.setItem(SAVE_KEY, JSON.stringify(data));
+ } catch {
+ // storage unavailable, ignore
+ }
+ }
+
+ private load() {
+ try {
+ const raw = localStorage.getItem(SAVE_KEY);
+ if (!raw) return;
+ const data = JSON.parse(raw) as Partial;
+ this.score = data.score ?? 0;
+ this.zone = data.zone ?? 1;
+ this.crystalsInZone = data.crystalsInZone ?? 0;
+ this.darkMatter = data.darkMatter ?? 0;
+ this.levels = {
+ crystal: data.levels?.crystal ?? 1,
+ speed: data.levels?.speed ?? 1,
+ damage: data.levels?.damage ?? 1,
+ droid: data.levels?.droid ?? 1,
+ };
+ } catch {
+ // corrupted save, start fresh
+ }
+ }
+}
diff --git a/src/game/constants.ts b/src/game/constants.ts
new file mode 100644
index 0000000..7d691f0
--- /dev/null
+++ b/src/game/constants.ts
@@ -0,0 +1,89 @@
+import type { 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,
+ CONTACT_RANGE: 46,
+ COLLECT_RANGE: 26,
+ PIRATE_HP_ZONE_MULT: 1.4,
+ CRYSTAL_VALUE_ZONE_MULT: 1.25,
+ BASE_PIRATE_HP: 24,
+ BASE_PIRATE_SPEED: 72,
+ BASE_BOSS_SPEED: 46,
+ BASE_CRYSTAL_VALUE: 5,
+ BASE_KNIGHT_SPEED: 260,
+ BASE_KNIGHT_DAMAGE: 9,
+ ATTACK_INTERVAL: 0.55,
+ MANUAL_CLICK_MULT: 3,
+ DROID_ORBIT_RADIUS: 50,
+ DROID_INTERVAL: 2,
+ DROID_DAMAGE_RATIO: 0.5,
+ PRESTIGE_MIN_ZONE: 20,
+ KNIGHT_HIT_RADIUS: 16,
+ MOVE_ARRIVE_EPS: 4,
+ TRAIL_INTERVAL: 0.045,
+ ASTEROID_SPAWN_INTERVAL: 2,
+ ASTEROID_MIN_RADIUS: 12,
+ ASTEROID_MAX_RADIUS: 36,
+ ASTEROID_SCORE_LOSS_RATIO: 0.1,
+ GOLDEN_ASTEROID_INTERVAL: 30,
+ GOLDEN_ASTEROID_RADIUS: 22,
+ GOLDEN_ASTEROID_REWARD_MULT: 20,
+ SUPERNOVA_DURATION: 10,
+ SUPERNOVA_DARK_MATTER_GAIN: 1,
+} as const;
+
+export const UPGRADES: Record = {
+ crystal: {
+ title: 'Призвать Кристалл Бездны',
+ desc: '+25% к стоимости кристаллов за уровень',
+ icon: '\u{1F48E}',
+ baseCost: 40,
+ growth: 1.15,
+ bonus: 0.25,
+ },
+ speed: {
+ title: 'Турбо-Ранцы',
+ desc: '+15% к скорости полёта и бега',
+ icon: '\u{1F680}',
+ baseCost: 35,
+ growth: 1.15,
+ bonus: 0.15,
+ },
+ damage: {
+ title: 'Фокусировка Кристалла Меча',
+ desc: '+30% к урону меча и клика',
+ icon: '\u{2694}\u{FE0F}',
+ baseCost: 65,
+ growth: 1.17,
+ bonus: 0.3,
+ },
+ droid: {
+ title: 'Охранный Дроид',
+ desc: 'Дроид атакует раз в 2с на 50% урона',
+ icon: '\u{1F6F0}\u{FE0F}',
+ baseCost: 260,
+ growth: 1.65,
+ bonus: 0,
+ },
+};
+
+export interface DroidTierConfig {
+ minLevel: number;
+ radius: number;
+ spikes: number;
+ color: string;
+ stroke: string;
+ laserWidth: number;
+ glow: number;
+}
+
+export const DROID_TIERS: DroidTierConfig[] = [
+ { minLevel: 1, radius: 9, spikes: 0, color: '#ffd35b', stroke: '#a8791b', laserWidth: 2, glow: 4 },
+ { minLevel: 5, radius: 12, spikes: 8, color: '#ff9a3c', stroke: '#a85a1b', laserWidth: 3, glow: 8 },
+ { minLevel: 10, radius: 15, spikes: 10, color: '#ff5ad1', stroke: '#8a1b6e', laserWidth: 4, glow: 12 },
+ { minLevel: 20, radius: 19, spikes: 12, color: '#ffffff', stroke: '#c81b6e', laserWidth: 6, glow: 20 },
+];
diff --git a/src/game/format.ts b/src/game/format.ts
new file mode 100644
index 0000000..056bd36
--- /dev/null
+++ b/src/game/format.ts
@@ -0,0 +1,24 @@
+const SUFFIXES = [
+ 'K', 'M', 'B', 'T', 'Qa', 'Qi', 'Sx', 'Sp', 'Oc', 'No', 'Dc',
+ 'Ud', 'Dd', 'Td', 'Qad', 'Qid', 'Sxd', 'Spd', 'Ocd', 'Nod', 'Vg',
+];
+
+export function formatNumber(value: number): string {
+ if (!Number.isFinite(value)) return '∞';
+ const sign = value < 0 ? '-' : '';
+ const abs = Math.abs(value);
+ if (abs < 1000) {
+ return sign + (Number.isInteger(abs) ? abs.toString() : abs.toFixed(1));
+ }
+
+ let tier = Math.floor(Math.log10(abs) / 3) - 1;
+ if (tier >= SUFFIXES.length) tier = SUFFIXES.length - 1;
+
+ let scaled = abs / Math.pow(1000, tier + 1);
+ if (Math.round(scaled * 10) / 10 >= 1000 && tier < SUFFIXES.length - 1) {
+ tier += 1;
+ scaled = abs / Math.pow(1000, tier + 1);
+ }
+
+ return `${sign}${scaled.toFixed(1)} ${SUFFIXES[tier]}`;
+}
diff --git a/src/game/random.ts b/src/game/random.ts
new file mode 100644
index 0000000..f1938a2
--- /dev/null
+++ b/src/game/random.ts
@@ -0,0 +1,16 @@
+export function mulberry32(seed: number) {
+ let state = seed | 0;
+ return function random() {
+ state = (state + 0x6d2b79f5) | 0;
+ let t = Math.imul(state ^ (state >>> 15), 1 | state);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+export function makeJitter(seed: number, count: number, amount = 0.18): number[] {
+ const rand = mulberry32(seed);
+ const arr: number[] = [];
+ for (let i = 0; i < count; i++) arr.push(1 - amount + rand() * amount * 2);
+ return arr;
+}
diff --git a/src/game/types.ts b/src/game/types.ts
new file mode 100644
index 0000000..7da624e
--- /dev/null
+++ b/src/game/types.ts
@@ -0,0 +1,99 @@
+export type UpgradeKey = 'crystal' | 'speed' | 'damage' | 'droid';
+
+export interface CrystalEntity {
+ id: number;
+ x: number;
+ y: number;
+ value: number;
+ radius: number;
+ rotation: number;
+ spin: number;
+ jitter: number[];
+}
+
+export interface EnemyEntity {
+ id: number;
+ x: number;
+ y: number;
+ hp: number;
+ maxHp: number;
+ speed: number;
+ radius: number;
+ isBoss: boolean;
+ state: 'chase' | 'fight';
+ wanderAngle: number;
+ jitter: number[];
+ hitFlash: number;
+}
+
+export interface DroidEntity {
+ id: number;
+ angleOffset: number;
+ cooldown: number;
+ laserTimer: number;
+}
+
+export interface AsteroidEntity {
+ id: number;
+ x: number;
+ y: number;
+ vx: number;
+ speed: number;
+ radius: number;
+ rotation: number;
+ spin: number;
+ jitter: number[];
+}
+
+export interface GoldenAsteroidEntity {
+ id: number;
+ x: number;
+ y: number;
+ vx: number;
+ vy: number;
+ radius: number;
+ rotation: number;
+ jitter: number[];
+}
+
+export interface Particle {
+ x: number;
+ y: number;
+ vx: number;
+ vy: number;
+ life: number;
+ maxLife: number;
+ size: number;
+ color: string;
+}
+
+export interface FloatingText {
+ x: number;
+ y: number;
+ vy: number;
+ life: number;
+ maxLife: number;
+ text: string;
+ color: string;
+}
+
+export interface GameSnapshot {
+ score: number;
+ zone: number;
+ crystalsInZone: number;
+ crystalsRequired: number;
+ darkMatter: number;
+ bossActive: boolean;
+ bossHp: number;
+ bossMaxHp: number;
+ pirateActive: boolean;
+ pirateHp: number;
+ pirateMaxHp: number;
+ levels: Record;
+ costs: Record;
+ canAfford: Record;
+ droidCount: number;
+ canPrestige: boolean;
+ prestigeGain: number;
+ cutsceneActive: boolean;
+}
diff --git a/src/hooks/useGameEngine.ts b/src/hooks/useGameEngine.ts
new file mode 100644
index 0000000..a3b58b6
--- /dev/null
+++ b/src/hooks/useGameEngine.ts
@@ -0,0 +1,24 @@
+import { useEffect, useRef, useState, type RefObject } from 'react';
+import { GameEngine } from '../game/GameEngine';
+import type { GameSnapshot } from '../game/types';
+
+export function useGameEngine(canvasRef: RefObject) {
+ const engineRef = useRef(null);
+ const [snapshot, setSnapshot] = useState(null);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const engine = new GameEngine(canvas);
+ engineRef.current = engine;
+ const unsubscribe = engine.subscribe(setSnapshot);
+ engine.start();
+ return () => {
+ unsubscribe();
+ engine.stop();
+ engineRef.current = null;
+ };
+ }, [canvasRef]);
+
+ return { engine: engineRef, snapshot };
+}
diff --git a/src/index.css b/src/index.css
index 5fb3313..83372b6 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,111 +1,22 @@
:root {
- --text: #6b6375;
- --text-h: #08060d;
- --bg: #fff;
- --border: #e5e4e7;
- --code-bg: #f4f3ec;
- --accent: #aa3bff;
- --accent-bg: rgba(170, 59, 255, 0.1);
- --accent-border: rgba(170, 59, 255, 0.5);
- --social-bg: rgba(244, 243, 236, 0.5);
- --shadow:
- rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
-
- --sans: system-ui, 'Segoe UI', Roboto, sans-serif;
- --heading: system-ui, 'Segoe UI', Roboto, sans-serif;
- --mono: ui-monospace, Consolas, monospace;
-
- font: 18px/145% var(--sans);
- letter-spacing: 0.18px;
- color-scheme: light dark;
- color: var(--text);
- background: var(--bg);
+ color-scheme: dark;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-
- @media (max-width: 1024px) {
- font-size: 16px;
- }
}
-@media (prefers-color-scheme: dark) {
- :root {
- --text: #9ca3af;
- --text-h: #f3f4f6;
- --bg: #16171d;
- --border: #2e303a;
- --code-bg: #1f2028;
- --accent: #c084fc;
- --accent-bg: rgba(192, 132, 252, 0.15);
- --accent-border: rgba(192, 132, 252, 0.5);
- --social-bg: rgba(47, 48, 58, 0.5);
- --shadow:
- rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
- }
-
- #social .button-icon {
- filter: invert(1) brightness(2);
- }
-}
-
-#root {
- width: 1126px;
- max-width: 100%;
- margin: 0 auto;
- text-align: center;
- border-inline: 1px solid var(--border);
- min-height: 100svh;
- display: flex;
- flex-direction: column;
+* {
box-sizing: border-box;
}
+html,
+body,
+#root {
+ height: 100%;
+ margin: 0;
+}
+
body {
- margin: 0;
-}
-
-h1,
-h2 {
- font-family: var(--heading);
- font-weight: 500;
- color: var(--text-h);
-}
-
-h1 {
- font-size: 56px;
- letter-spacing: -1.68px;
- margin: 32px 0;
- @media (max-width: 1024px) {
- font-size: 36px;
- margin: 20px 0;
- }
-}
-h2 {
- font-size: 24px;
- line-height: 118%;
- letter-spacing: -0.24px;
- margin: 0 0 8px;
- @media (max-width: 1024px) {
- font-size: 20px;
- }
-}
-p {
- margin: 0;
-}
-
-code,
-.counter {
- font-family: var(--mono);
- display: inline-flex;
- border-radius: 4px;
- color: var(--text-h);
-}
-
-code {
- font-size: 15px;
- line-height: 135%;
- padding: 4px 8px;
- background: var(--code-bg);
+ overflow: hidden;
}
Connect with us
-Join the Vite community
---
-
-
- GitHub
-
-
- -
-
-
- Discord
-
-
- -
-
-
- X.com
-
-
- -
-
-
- Bluesky
-
-
-
-