From 7a62067af12c3dae12eb22dcb8fcf9c2a8e3a7f2 Mon Sep 17 00:00:00 2001 From: Mareli Date: Sun, 19 Apr 2026 12:31:49 +0300 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20Arcanum=20TD=20=E2=80=94=20?= =?UTF-8?q?medieval=20fantasy=20tower=20defense?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vite + React + PixiJS + TypeScript. Features: - 4-level campaign (King's Road → Obsidian Keep) - Isometric 2.5D grid with ley-line mechanics - ECS architecture (entities, components, systems) - 4 tower types, hero spellcaster, 10+ enemy types - Lich King boss with 3-phase AI - Meta-progression: essence, rune unlocks - Full UI redesign with fantasy design system Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 24 + PLAN.md | 349 ++ README.md | 73 + eslint.config.js | 23 + index.html | 19 + package-lock.json | 4396 ++++++++++++++++++++ package.json | 39 + postcss.config.js | 6 + public/favicon.svg | 1 + public/icons.svg | 24 + src/App.tsx | 24 + src/assets/hero.png | Bin 0 -> 44919 bytes src/assets/react.svg | 1 + src/assets/vite.svg | 1 + src/data/levels.ts | 28 + src/data/levels/frostfall_pass.ts | 28 + src/data/levels/kings_road.ts | 27 + src/data/levels/obsidian_keep.ts | 28 + src/data/levels/whispering_woods.ts | 28 + src/data/towerDefs.ts | 58 + src/data/waves.ts | 94 + src/game/LevelScene.ts | 627 +++ src/game/combat/DamageResolver.ts | 17 + src/game/components/Hero.ts | 73 + src/game/components/StatusEffect.ts | 32 + src/game/components/index.ts | 73 + src/game/core/EntityManager.ts | 46 + src/game/core/EventBus.ts | 36 + src/game/core/GameEngine.ts | 114 + src/game/core/Time.ts | 9 + src/game/entities/enemies/dragon.ts | 156 + src/game/entities/enemies/goblin.ts | 100 + src/game/entities/enemies/golem.ts | 121 + src/game/entities/enemies/lich_king.ts | 203 + src/game/entities/enemies/necromancer.ts | 129 + src/game/entities/enemies/orc.ts | 104 + src/game/entities/enemies/troll.ts | 113 + src/game/entities/enemies/updateHpBar.ts | 20 + src/game/entities/enemies/warg.ts | 101 + src/game/entities/enemies/wraith.ts | 88 + src/game/entities/hero/archmage.ts | 110 + src/game/entities/projectiles/arrow.ts | 46 + src/game/entities/projectiles/fireball.ts | 39 + src/game/entities/projectiles/icicle.ts | 33 + src/game/entities/projectiles/lightning.ts | 36 + src/game/entities/towers/archer.ts | 107 + src/game/entities/towers/cryomancer.ts | 97 + src/game/entities/towers/pyromancer.ts | 89 + src/game/entities/towers/stormcaller.ts | 94 + src/game/map/GridMap.ts | 99 + src/game/map/LeyLines.ts | 29 + src/game/map/Pathfinding.ts | 49 + src/game/rendering/MapRenderer.ts | 181 + src/game/rendering/PixiRoot.ts | 82 + src/game/rendering/WorldContext.ts | 12 + src/game/rendering/camera.ts | 115 + src/game/systems/AttackSystem.ts | 39 + src/game/systems/DeathSystem.ts | 76 + src/game/systems/HeroSystem.ts | 61 + src/game/systems/LichKingSystem.ts | 169 + src/game/systems/MovementSystem.ts | 49 + src/game/systems/NecromancerSystem.ts | 57 + src/game/systems/ProjectileSystem.ts | 225 + src/game/systems/RenderSystem.ts | 27 + src/game/systems/StatusEffectSystem.ts | 50 + src/game/systems/TargetingSystem.ts | 56 + src/game/systems/WaveSystem.ts | 119 + src/lib/math.ts | 16 + src/lib/rng.ts | 16 + src/lib/save.ts | 53 + src/main.tsx | 15 + src/state/gameStore.ts | 85 + src/state/metaStore.ts | 48 + src/state/settingsStore.ts | 53 + src/styles/globals.css | 345 ++ src/ui/hud/BossCutscene.tsx | 113 + src/ui/hud/GameHUD.tsx | 231 + src/ui/hud/SpellBar.tsx | 130 + src/ui/hud/TowerInfoPanel.tsx | 116 + src/ui/screens/CampaignMap.tsx | 218 + src/ui/screens/GameOver.tsx | 134 + src/ui/screens/GameScreen.tsx | 54 + src/ui/screens/MainMenu.tsx | 127 + src/ui/screens/Settings.tsx | 108 + src/ui/screens/TomeOfRunes.tsx | 121 + src/workers/pathfinding.worker.ts | 93 + tailwind.config.js | 103 + tsconfig.app.json | 28 + tsconfig.json | 7 + tsconfig.node.json | 24 + vite.config.ts | 15 + 91 files changed, 11832 insertions(+) create mode 100644 .gitignore create mode 100644 PLAN.md create mode 100644 README.md create mode 100644 eslint.config.js create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 public/favicon.svg create mode 100644 public/icons.svg create mode 100644 src/App.tsx create mode 100644 src/assets/hero.png create mode 100644 src/assets/react.svg create mode 100644 src/assets/vite.svg create mode 100644 src/data/levels.ts create mode 100644 src/data/levels/frostfall_pass.ts create mode 100644 src/data/levels/kings_road.ts create mode 100644 src/data/levels/obsidian_keep.ts create mode 100644 src/data/levels/whispering_woods.ts create mode 100644 src/data/towerDefs.ts create mode 100644 src/data/waves.ts create mode 100644 src/game/LevelScene.ts create mode 100644 src/game/combat/DamageResolver.ts create mode 100644 src/game/components/Hero.ts create mode 100644 src/game/components/StatusEffect.ts create mode 100644 src/game/components/index.ts create mode 100644 src/game/core/EntityManager.ts create mode 100644 src/game/core/EventBus.ts create mode 100644 src/game/core/GameEngine.ts create mode 100644 src/game/core/Time.ts create mode 100644 src/game/entities/enemies/dragon.ts create mode 100644 src/game/entities/enemies/goblin.ts create mode 100644 src/game/entities/enemies/golem.ts create mode 100644 src/game/entities/enemies/lich_king.ts create mode 100644 src/game/entities/enemies/necromancer.ts create mode 100644 src/game/entities/enemies/orc.ts create mode 100644 src/game/entities/enemies/troll.ts create mode 100644 src/game/entities/enemies/updateHpBar.ts create mode 100644 src/game/entities/enemies/warg.ts create mode 100644 src/game/entities/enemies/wraith.ts create mode 100644 src/game/entities/hero/archmage.ts create mode 100644 src/game/entities/projectiles/arrow.ts create mode 100644 src/game/entities/projectiles/fireball.ts create mode 100644 src/game/entities/projectiles/icicle.ts create mode 100644 src/game/entities/projectiles/lightning.ts create mode 100644 src/game/entities/towers/archer.ts create mode 100644 src/game/entities/towers/cryomancer.ts create mode 100644 src/game/entities/towers/pyromancer.ts create mode 100644 src/game/entities/towers/stormcaller.ts create mode 100644 src/game/map/GridMap.ts create mode 100644 src/game/map/LeyLines.ts create mode 100644 src/game/map/Pathfinding.ts create mode 100644 src/game/rendering/MapRenderer.ts create mode 100644 src/game/rendering/PixiRoot.ts create mode 100644 src/game/rendering/WorldContext.ts create mode 100644 src/game/rendering/camera.ts create mode 100644 src/game/systems/AttackSystem.ts create mode 100644 src/game/systems/DeathSystem.ts create mode 100644 src/game/systems/HeroSystem.ts create mode 100644 src/game/systems/LichKingSystem.ts create mode 100644 src/game/systems/MovementSystem.ts create mode 100644 src/game/systems/NecromancerSystem.ts create mode 100644 src/game/systems/ProjectileSystem.ts create mode 100644 src/game/systems/RenderSystem.ts create mode 100644 src/game/systems/StatusEffectSystem.ts create mode 100644 src/game/systems/TargetingSystem.ts create mode 100644 src/game/systems/WaveSystem.ts create mode 100644 src/lib/math.ts create mode 100644 src/lib/rng.ts create mode 100644 src/lib/save.ts create mode 100644 src/main.tsx create mode 100644 src/state/gameStore.ts create mode 100644 src/state/metaStore.ts create mode 100644 src/state/settingsStore.ts create mode 100644 src/styles/globals.css create mode 100644 src/ui/hud/BossCutscene.tsx create mode 100644 src/ui/hud/GameHUD.tsx create mode 100644 src/ui/hud/SpellBar.tsx create mode 100644 src/ui/hud/TowerInfoPanel.tsx create mode 100644 src/ui/screens/CampaignMap.tsx create mode 100644 src/ui/screens/GameOver.tsx create mode 100644 src/ui/screens/GameScreen.tsx create mode 100644 src/ui/screens/MainMenu.tsx create mode 100644 src/ui/screens/Settings.tsx create mode 100644 src/ui/screens/TomeOfRunes.tsx create mode 100644 src/workers/pathfinding.worker.ts create mode 100644 tailwind.config.js create mode 100644 tsconfig.app.json create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..7d39727 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,349 @@ +# Arcanum: Wardens of the Realm +**Tower Defense в средневеково-магической стилистике. Веб. План на реализацию (исполнитель — Sonnet).** + +--- + +## 0. Концепция + +Игрок — последний Архимаг угасающего королевства. Он защищает цитадель от вторжения Тёмного Ковенанта, расставляя на древних каменных башнях **рунические печати**, через которые течёт магия стихий. По карте идут **лей-линии** — потоки маны, усиливающие башни рядом. Сам Архимаг — подвижный герой с четырьмя заклинаниями. + +**Три столпа геймплея:** +1. **Рунный синергизм** — у каждой башни 1 основная + 2 слота вторичных рун; руны комбинируются в уникальные эффекты. +2. **Лей-линии** — «магический рельеф» карты, создающий задачу оптимального размещения. +3. **Архимаг** — активный игровой элемент поверх пассивной экономики TD; позиционирование и таймингы заклинаний решают исходы волн. + +**Мета-слой (roguelike-lite):** между забегами открываются новые руны в **Томе Рун** за эссенцию, выпадающую с боссов. + +--- + +## 1. Технологический стек + +| Слой | Технология | Зачем | +|---|---|---| +| Build | **Vite + TypeScript** | Быстрый HMR, строгая типизация | +| Рендеринг игры | **PixiJS v8** (WebGPU-first) | 2D/2.5D, шейдеры, сотни юнитов на экране | +| UI / меню / HUD | **React 18** | Декларативный UI поверх canvas | +| Стили | **TailwindCSS + shadcn/ui** | Быстрая сборка красивых меню | +| Состояние | **Zustand** | Лёгкий стор, подписки из Pixi и React | +| Анимации UI | **GSAP** | Переходы, ink-wipe, rune-glow | +| Аудио | **Howler.js** | Кроссбраузерный звук, пулы | +| Многопоток | **Web Workers** | A* pathfinding вне основного потока | +| Шрифты | **Cinzel** (заголовки), **EB Garamond** (текст), **MedievalSharp** (акценты) | Средневековая типографика | +| Тесты | **Vitest** + **Playwright** (smoke) | Юнит + e2e одного уровня | +| Деплой | **Vercel** / **Netlify** | Статический билд | + +**Почему не Phaser/Three.js:** Phaser диктует архитектуру и «чувствуется как движок»; Three избыточен для 2.5D. PixiJS даёт чистый рендерер — больше свободы для собственной ECS и визуальных эффектов. + +--- + +## 2. Визуальное направление («красивый веб») + +- **Стиль:** изометрическая 2.5D-проекция, hand-painted look (опорно — вайб *Bastion* / *Octopath HD-2D*). +- **Палитра:** глубокая полночь (#0E1220) + обожжённое золото (#C9A14A) + уголь (#E8702A) + морозный циан (#6ECBD5) + пергамент (#E9DCC0). +- **Динамический свет:** каждая башня излучает цветной свет (огонь пульсирует, мороз мерцает, арканум медленно вращается). +- **Постэффекты:** bloom на магических снарядах, лёгкая хроматическая аберрация на боссовых ударах, цветокоррекция по биому. +- **Частицы:** угольки, снег, руны-светлячки вокруг лей-линий, дым от смерти врагов. +- **UI-каркас:** пергамент + золотая филигрань (CSS `border-image` + SVG), кнопки с **rune-glow** на hover, иконки — нарисованные от руки. +- **Переходы:** чернильное клякса-вайп между меню и игрой. +- **Превью размещения:** призрачная башня + пульсирующий круг радиуса с рунным узором на земле. + +--- + +## 3. Контент MVP + +### 3.1 Башни (6 архетипов, по 3 тира) + +| Башня | Урон | Особенность | +|---|---|---| +| Archers' Loft | физ | дальняя, точная, single-target | +| Pyromancer Spire | огонь | AoE + burn DoT | +| Cryomancer Sanctum | лёд | slow → freeze → shatter-комбо | +| Stormcaller Obelisk | молния | chain-lightning по 3-5 целям | +| Chapel of Light | поддержка | хилит стены, баффает соседние башни | +| Bone Totem | призыв | поднимает скелета из трупа врага | + +### 3.2 Руны-модификаторы (12, открываются мета-прогрессией) +Pierce, Chain, Seek, Burn, Frost, Shock, Vampire, Echo, Curse, Weight, Fracture, Hex. Каждая даёт измеримый эффект + визуальный маркер на башне. + +### 3.3 Враги (9) +Goblin Scout (быстрый), Orc Brute (танк), Warg Rider (swarm), Wraith (фазовый — только магия), Cursed Troll (регенит), Siege Golem (броня), Necromancer (воскрешает соседей), Dragon (летающий босс), Lich-King (финальный босс, 3 фазы). + +### 3.4 Герой — **The Archmage** +- Ходит по карте по клику. +- 4 активных заклинания: **Fireball** (AoE-урон), **Blizzard** (AoE-slow), **Blink** (телепорт), **Time Warp** (глобальный slow 5 сек). +- Получает XP с каждой волны, улучшает заклинания по дереву навыков. + +### 3.5 Экономика +- **Gold** — с убийств → башни/апгрейды. +- **Mana** — регенерит сам, бонус от лей-линий → заклинания героя. +- **Essence** — падает с боссов, не тратится внутри забега → Том Рун между забегами. + +### 3.6 Волны +20 волн на карту, каждая 5-я — элитная, каждая 10-я — босс. Все волны описаны декларативно (TS-конфиги): состав, тайминги, паттерн спавна. + +### 3.7 Карты MVP (3 + босс-арена) +1. **King's Road** — открытые поля, один путь (туториальная). +2. **Whispering Woods** — ветвление, враги делятся. +3. **Frostfall Pass** — горный проход, узкие точки, снежная погода. +4. **Obsidian Keep** — финальный босс, 3 фазы. + +--- + +## 4. Архитектура + +### 4.1 Слои + +``` +┌─────────────────────────────────────────┐ +│ React UI (меню, HUD, модалки) │ +└────────────────┬────────────────────────┘ + │ hooks (useGameStore) +┌────────────────▼────────────────────────┐ +│ Zustand — общий стор (game, meta, set.) │ +└────────────────┬────────────────────────┘ + │ подписки +┌────────────────▼────────────────────────┐ +│ Game Core — ECS-lite (Entities + Systems)│ +└────────────────┬────────────────────────┘ + │ рендер-команды +┌────────────────▼────────────────────────┐ +│ PixiJS — слои, спрайты, шейдеры, частицы│ +└─────────────────────────────────────────┘ +``` + +### 4.2 ECS-lite + +Сущности — объекты с `id`. Компоненты — чистые дата-классы. Системы — функции `update(dt, world)`. + +**Компоненты (пример):** +```ts +TransformComponent { x, y, rotation } +HealthComponent { current, max, armor, magicResist } +MovementComponent { speed, path, pathIndex } +TargetingComponent { range, mode: 'first'|'last'|'strongest'|'weakest' } +AttackComponent { damage, type, cooldown, projectile, runes: RuneId[] } +StatusEffectsComp { effects: Effect[] } +RenderComponent { sprite, layer, anim } +TeamComponent { team: 'player'|'enemy' } +LootComponent { gold, essence } +``` + +**Системы (порядок выполнения за тик):** +`InputSystem → WaveSystem → SpawnSystem → MovementSystem → TargetingSystem → AttackSystem → ProjectileSystem → StatusEffectSystem → CollisionSystem → DeathSystem → EconomySystem → RenderSystem` + +### 4.3 Data-driven контент + +Все башни/враги/волны/руны — типизированные TS-конфиги (единый источник правды, дружелюбен к балансу). + +```ts +// src/data/towers.ts +export const TOWERS: Record = { + archers_loft: { + id: 'archers_loft', + name: "Archers' Loft", + tiers: [ + { cost: 50, damage: 8, range: 180, cooldown: 0.8 }, + { cost: 75, damage: 14, range: 200, cooldown: 0.7 }, + { cost: 120, damage: 22, range: 220, cooldown: 0.6 }, + ], + projectile: 'arrow', + damageType: 'physical', + sprite: 'towers/archers_loft', + }, + // … +} +``` + +### 4.4 Структура проекта + +``` +tower-def/ +├── index.html +├── package.json +├── vite.config.ts +├── tsconfig.json +├── tailwind.config.ts +├── public/ +│ └── assets/ { sprites/, audio/, fonts/, shaders/ } +├── src/ +│ ├── main.tsx +│ ├── App.tsx +│ ├── ui/ # React +│ │ ├── screens/ { MainMenu, CampaignMap, GameHUD, TomeOfRunes, Settings, GameOver } +│ │ ├── hud/ { WaveIndicator, GoldBar, ManaBar, TowerShop, TowerInfoPanel, SpellBar } +│ │ └── components/ # shadcn wrappers +│ ├── game/ +│ │ ├── core/ # GameEngine, EntityManager, EventBus, Time +│ │ ├── components/ # ECS data-классы +│ │ ├── systems/ # ECS логика +│ │ ├── entities/ { towers/, enemies/, projectiles/, hero/ } +│ │ ├── rendering/ { PixiRoot, layers, shaders/, particles/, camera } +│ │ ├── map/ { GridMap, Pathfinding, LeyLines, TilePalette } +│ │ ├── combat/ { DamageResolver, RuneEffects, Synergies } +│ │ ├── economy/ +│ │ ├── audio/ # Howler-обёртка +│ │ └── input/ # pointer, keyboard, pan/zoom +│ ├── state/ # Zustand сторы +│ │ ├── gameStore.ts +│ │ ├── metaStore.ts +│ │ ├── settingsStore.ts +│ │ └── selectors.ts +│ ├── data/ # контент (TS) +│ │ ├── towers.ts +│ │ ├── enemies.ts +│ │ ├── runes.ts +│ │ ├── waves/ +│ │ └── levels/ +│ ├── workers/ +│ │ └── pathfinding.worker.ts +│ ├── lib/ { save, rng, math } +│ └── styles/globals.css +└── tests/ { unit/, e2e/ } +``` + +### 4.5 Ключевые архитектурные решения + +- **Фиксированный tick (60 Hz)** + интерполяция при рендере → детерминированная симуляция. +- **Object pooling** для снарядов, частиц, всплывающих цифр — без GC-stutter. +- **Pathfinding в воркере** — карта блокирует тайлы, воркер пересчитывает путь, возвращает массив точек. +- **Seeded RNG** (`mulberry32`) — воспроизводимые забеги, детерминированные тесты. +- **Save/load**: `localStorage` + версия схемы + миграции. +- **Event Bus** для одиночных событий (смерть, конец волны, босс-триггер); тонкая обвязка над `EventTarget`. + +--- + +## 5. План разработки по фазам + +Каждая фаза заканчивается **играбельным билдом**. Sonnet прогоняет `npm run build` и проходит ключевой сценарий вручную перед тем, как двигаться дальше. + +### Фаза 0 — Bootstrap (день 1) +- Vite + TS + React + Tailwind + shadcn/ui. +- Монтирование PixiJS-канваса внутри React. +- Скелет Zustand-сторов. +- Главное меню → пустой экран игры. +- **Готово:** кликабельное меню, чёрный канвас с FPS-оверлеем. + +### Фаза 1 — Сетка и путь (дни 2-3) +- `GridMap` с типами тайлов (buildable / path / blocked / ley-line). +- Изометрический рендер + параллакс-фон. +- A* в воркере. +- Визуализация пути (debug-overlay). +- **Готово:** видна сетка, клики меняют тайлы, путь рисуется. + +### Фаза 2 — Первый враг + первая башня (дни 4-6) +- ECS-ядро (Entity, Component, System). +- Один враг идёт по пути. +- Башня «Archer»: размещение, таргет, снаряд, урон. +- Health-bar, death-анимация. +- **Готово:** вертикальный срез — башня убивает гоблина. + +### Фаза 3 — Экономика и волны (дни 7-8) +- Gold с убийств, покупка башен. +- `WaveSystem` из конфигов, countdown между волнами. +- Условия победы/поражения (HP нексуса). +- Базовый HUD. +- **Готово:** полная петля на 5 волн. + +### Фаза 4 — Ростер башен (дни 9-11) +- Все 6 архетипов с уникальным поведением. +- Типы снарядов (arrow/fireball/icicle/chain/heal/summon). +- 3 тира улучшений. +- Превью радиуса. + +### Фаза 5 — Ростер врагов + эффекты (дни 12-14) +- Все 9 типов врагов. +- Фреймворк статус-эффектов (burn/freeze/shock/curse). +- Типы урона и резистов. +- Рунные слоты + первые 6 рун. + +### Фаза 6 — Архимаг (дни 15-17) +- Подвижный герой. +- 4 заклинания с кулдаунами и маной. +- UI таргетинга. +- Дерево прокачки. + +### Фаза 7 — Карты + мета (дни 18-20) +- Экран кампании. +- 3 уровня MVP с разными биомами. +- Том Рун (мета-экран). +- Save/load с версионированием. + +### Фаза 8 — Босс + глубина контента (дни 21-23) +- Боссы с фазами. +- Финальный уровень «Obsidian Keep». +- Минимальные cutscene-кадры (парящие иллюстрации + текст). + +### Фаза 9 — Juice и полировка (дни 24-27) +- Частицы для всех заклинаний. +- Шейдеры (fire, frost, arcane glow). +- Camera shake, hit-flashes, damage-numbers. +- Амбиент + музыкальные треки (3-4) + sfx. +- GSAP-анимации меню. +- Экран настроек (аудио, разрешение, доступность). + +### Фаза 10 — Баланс и релиз (дни 28-30) +- Прогон всех волн, балансировка. +- Режимы сложности (Normal / Heroic / Nightmare). +- Performance-проход (pooling, batching). +- Билд и деплой. + +**Итого:** ~30 дней разработки для одного разработчика (Sonnet) при полной занятости. Приоритет при нехватке времени: фазы 0-3 (playable core) > 4-5 (контент) > 9 (полировка) > 6-8 (герой/мета) — их можно урезать. + +--- + +## 6. Определения «готово» (Definition of Done) + +- ✅ TypeScript **strict**, zero `any` в прод-коде. +- ✅ `npm run build` проходит без warnings. +- ✅ 60 FPS на MacBook M1 при 80 врагах на экране. +- ✅ Первый paint < 2 сек, бандл < 2 МБ (gzip). +- ✅ Поддержка Chrome/Firefox/Safari последних 2 версий. +- ✅ Работает на тач-устройствах (iPad-первого класса). +- ✅ Клавиатурная навигация по меню. +- ✅ Минимум 1 юнит-тест для: `DamageResolver`, `Pathfinding`, `WaveSystem`, `SaveSystem`. + +--- + +## 7. Риски и как их смять + +| Риск | Смягчение | +|---|---| +| Перфоманс падает при 200+ врагах | Object pooling + sprite batching + culling по камере | +| Pathfinding блокирует рендер | Вынос в Web Worker с самого начала (фаза 1) | +| Переусложнение рун → нечитаемость | Лимит: 2 слота рун + визуальный маркер на башне + tooltip с итоговой формулой | +| React ре-рендерится слишком часто | Подписки Zustand с селекторами; HUD обновляется только по событиям волны/золота | +| Контент-бутылочное горло (арт) | MVP-арт: placeholder-спрайты (Kenney assets) → заменить в фазе 9; геймплей не ждёт графики | +| Звук блокируется автоплеем | Инициализация Howler по первому клику пользователя | + +--- + +## 8. Что не входит в MVP (осознанно отложено) + +- Мультиплеер / кооп. +- Редактор уровней. +- Локализация (только EN + RU-интерфейс, тексты в отдельном словаре для будущей i18n). +- Сторимод с развёрнутыми кат-сценами. +- Мобильная UI-оптимизация за пределами «работает на планшете». + +--- + +## 9. Что от Sonnet ожидается между фазами + +1. Обновить `PROGRESS.md` (создать в фазе 0) — одна строчка на фазу с датой. +2. Фиксировать ключевые решения в `DECISIONS.md` (ADR-стиль, 5-10 строк). +3. Запустить билд и smoke-прогон сценария фазы. Если что-то сломалось — **не двигаться дальше**. +4. Не вводить новые зависимости без явной причины (каждый npm-пакет добавляется с комментарием «зачем»). + +--- + +## 10. Первый шаг для Sonnet + +```bash +cd g:/Dev/Tower_Def +npm create vite@latest . -- --template react-ts +npm i pixi.js@8 zustand howler gsap +npm i -D tailwindcss postcss autoprefixer @types/howler vitest +npx tailwindcss init -p +``` + +Дальше — раздел 5, фаза 0. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7dbf7eb --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/index.html b/index.html new file mode 100644 index 0000000..081f1d9 --- /dev/null +++ b/index.html @@ -0,0 +1,19 @@ + + + + + + + Arcanum: Wardens of the Realm + + + + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..eaf04d8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4396 @@ +{ + "name": "tower-def", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tower-def", + "version": "0.0.0", + "dependencies": { + "gsap": "^3.15.0", + "howler": "^2.2.4", + "pixi.js": "^8.18.1", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "zustand": "^5.0.12" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/howler": "^2.2.12", + "@types/node": "^24.12.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "autoprefixer": "^10.5.0", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "postcss": "^8.5.10", + "tailwindcss": "^3.4.19", + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.0", + "vite": "^8.0.4", + "vitest": "^4.1.4" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@pixi/colord": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz", + "integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", + "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", + "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/earcut": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz", + "integrity": "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/howler": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@types/howler/-/howler-2.2.12.tgz", + "integrity": "sha512-hy769UICzOSdK0Kn1FBk4gN+lswcj1EKRkmiDtMkUGvFfYJzgaDXmVXkSShS2m89ERAatGIPnTUlp2HhfkVo5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", + "integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/type-utils": "8.58.2", + "@typescript-eslint/utils": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.58.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz", + "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.2.tgz", + "integrity": "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.2", + "@typescript-eslint/types": "^8.58.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz", + "integrity": "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz", + "integrity": "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz", + "integrity": "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/utils": "8.58.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz", + "integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz", + "integrity": "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.2", + "@typescript-eslint/tsconfig-utils": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.2.tgz", + "integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", + "integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", + "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.4", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "@vitest/utils": "4.1.4", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@webgpu/types": { + "version": "0.1.69", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", + "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.20.tgz", + "integrity": "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001788", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.340", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz", + "integrity": "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/gifuct-js": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz", + "integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==", + "license": "MIT", + "dependencies": { + "js-binary-schema-parser": "^2.0.3" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz", + "integrity": "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gsap": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz", + "integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==", + "license": "Standard 'no charge' license: https://gsap.com/standard-license." + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/howler": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/howler/-/howler-2.2.4.tgz", + "integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w==", + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/ismobilejs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz", + "integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==", + "license": "MIT" + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-binary-schema-parser": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz", + "integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==", + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-svg-path": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz", + "integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==", + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pixi.js": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.18.1.tgz", + "integrity": "sha512-6LUPWYgulZhp/w4kam2XHXB0QedISZIqrJbRdHLLQ3csn5a38uzKxAp6B5j6s89QFYaIJbg95kvgTRcbgpO1ow==", + "license": "MIT", + "workspaces": [ + "examples", + "playground" + ], + "dependencies": { + "@pixi/colord": "^2.9.6", + "@types/earcut": "^3.0.0", + "@webgpu/types": "^0.1.69", + "@xmldom/xmldom": "^0.8.12", + "earcut": "^3.0.2", + "eventemitter3": "^5.0.1", + "gifuct-js": "^2.1.2", + "ismobilejs": "^1.1.1", + "parse-svg-path": "^0.1.2", + "tiny-lru": "^11.4.7" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/pixijs" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", + "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.124.0", + "@rolldown/pluginutils": "1.0.0-rc.15" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-x64": "1.0.0-rc.15", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", + "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-lru": { + "version": "11.4.7", + "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz", + "integrity": "sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.2.tgz", + "integrity": "sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.58.2", + "@typescript-eslint/parser": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/utils": "8.58.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", + "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.15", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", + "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.4", + "@vitest/mocker": "4.1.4", + "@vitest/pretty-format": "4.1.4", + "@vitest/runner": "4.1.4", + "@vitest/snapshot": "4.1.4", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.4", + "@vitest/browser-preview": "4.1.4", + "@vitest/browser-webdriverio": "4.1.4", + "@vitest/coverage-istanbul": "4.1.4", + "@vitest/coverage-v8": "4.1.4", + "@vitest/ui": "4.1.4", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3fad9a5 --- /dev/null +++ b/package.json @@ -0,0 +1,39 @@ +{ + "name": "tower-def", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "gsap": "^3.15.0", + "howler": "^2.2.4", + "pixi.js": "^8.18.1", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "zustand": "^5.0.12" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/howler": "^2.2.12", + "@types/node": "^24.12.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "autoprefixer": "^10.5.0", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "postcss": "^8.5.10", + "tailwindcss": "^3.4.19", + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.0", + "vite": "^8.0.4", + "vitest": "^4.1.4" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons.svg b/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..df8098f --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,24 @@ +import { useGameStore } from '@/state/gameStore' +import { MainMenu } from '@/ui/screens/MainMenu' +import { CampaignMap } from '@/ui/screens/CampaignMap' +import { GameScreen } from '@/ui/screens/GameScreen' +import { TomeOfRunes } from '@/ui/screens/TomeOfRunes' +import { Settings } from '@/ui/screens/Settings' +import { GameOver } from '@/ui/screens/GameOver' + +export function App() { + const screen = useGameStore((s) => s.screen) + + return ( +
+ {screen === 'menu' && } + {screen === 'campaign' && } + {screen === 'game' && } + {screen === 'tome' && } + {screen === 'settings' && } + {screen === 'gameover' && } +
+ ) +} + +export default App diff --git a/src/assets/hero.png b/src/assets/hero.png new file mode 100644 index 0000000000000000000000000000000000000000..cc51a3d20ad4bc961b596a6adfd686685cd84bb0 GIT binary patch literal 44919 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 JPDHLkV1myZcL)Fg literal 0 HcmV?d00001 diff --git a/src/assets/react.svg b/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/vite.svg b/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/src/data/levels.ts b/src/data/levels.ts new file mode 100644 index 0000000..7eb4c5a --- /dev/null +++ b/src/data/levels.ts @@ -0,0 +1,28 @@ +export interface LevelDef { + id: string + name: string + cols: number + rows: number + layout: string[] + spawn: { x: number; y: number } + nexus: { x: number; y: number } + maxWaves: number + music: string +} + +export { KINGS_ROAD } from './levels/kings_road' +export { WHISPERING_WOODS } from './levels/whispering_woods' +export { FROSTFALL_PASS } from './levels/frostfall_pass' +export { OBSIDIAN_KEEP } from './levels/obsidian_keep' + +import { KINGS_ROAD } from './levels/kings_road' +import { WHISPERING_WOODS } from './levels/whispering_woods' +import { FROSTFALL_PASS } from './levels/frostfall_pass' +import { OBSIDIAN_KEEP } from './levels/obsidian_keep' + +export const LEVEL_MAP: Record = { + kings_road: KINGS_ROAD, + whispering_woods: WHISPERING_WOODS, + frostfall_pass: FROSTFALL_PASS, + obsidian_keep: OBSIDIAN_KEEP, +} diff --git a/src/data/levels/frostfall_pass.ts b/src/data/levels/frostfall_pass.ts new file mode 100644 index 0000000..b928539 --- /dev/null +++ b/src/data/levels/frostfall_pass.ts @@ -0,0 +1,28 @@ +import type { LevelDef } from '../levels' + +// P=path S=spawn N=nexus L=ley_line .=buildable +// Path: S(0,5)→right→(4,5)→up→(4,1)→right→(8,1)→down→(8,8)→right→(11,8)→up→(11,3)→right→(14,3)→down→(14,5)→N(15,5) +const layout = [ + '....LL.LL.......', + '....PPPPP.......', + '.LL.P...P...LL..', + '....P...P..PPPP.', + '....P...P..P..P.', + 'SPPPP...P..P..PN', + '........P..P....', + '........P..P....', + '........PPPP....', + '....LL......LL..', +] + +export const FROSTFALL_PASS: LevelDef = { + id: 'frostfall_pass', + name: 'Frostfall Pass', + cols: 16, + rows: 10, + layout, + spawn: { x: 0, y: 5 }, + nexus: { x: 15, y: 5 }, + maxWaves: 20, + music: 'theme_frost', +} diff --git a/src/data/levels/kings_road.ts b/src/data/levels/kings_road.ts new file mode 100644 index 0000000..39bdc7c --- /dev/null +++ b/src/data/levels/kings_road.ts @@ -0,0 +1,27 @@ +import type { LevelDef } from '../levels' + +// P=path S=spawn N=nexus L=ley_line .=buildable X=blocked +const layout = [ + 'SSPP.....L....', + '..PP.....L....', + '..PPPPPP.L....', + '......PP.L....', + '...L..PPLL....', + '...L..PP......', + '...L..PPPP....', + '...LLLLLLP....', + '.........PP...', + '..........PPNN', +] + +export const KINGS_ROAD: LevelDef = { + id: 'kings_road', + name: "King's Road", + cols: 14, + rows: 10, + layout, + spawn: { x: 0, y: 0 }, + nexus: { x: 12, y: 9 }, + maxWaves: 20, + music: 'theme_plains', +} diff --git a/src/data/levels/obsidian_keep.ts b/src/data/levels/obsidian_keep.ts new file mode 100644 index 0000000..ade4371 --- /dev/null +++ b/src/data/levels/obsidian_keep.ts @@ -0,0 +1,28 @@ +import type { LevelDef } from '../levels' + +// P=path S=spawn N=nexus L=ley_line .=buildable X=blocked +// Path: S(0,4)→(4,4)→(4,1)→(9,1)→(9,6)→(12,6)→(12,9)→N(13,9) +const layout = [ + 'XX....X...X.XX', + '....PPPPPP....', + '.X..P....P.LL.', + '....P....P....', + 'SPPPP....P....', + '.X.......P.LL.', + '.X...LL..PPPP.', + '.X...L...L..P.', + '.....LL..L..P.', + 'XX..........PN', +] + +export const OBSIDIAN_KEEP: LevelDef = { + id: 'obsidian_keep', + name: 'Obsidian Keep', + cols: 14, + rows: 10, + layout, + spawn: { x: 0, y: 4 }, + nexus: { x: 13, y: 9 }, + maxWaves: 10, + music: 'theme_boss', +} diff --git a/src/data/levels/whispering_woods.ts b/src/data/levels/whispering_woods.ts new file mode 100644 index 0000000..4ae3ec9 --- /dev/null +++ b/src/data/levels/whispering_woods.ts @@ -0,0 +1,28 @@ +import type { LevelDef } from '../levels' + +// P=path S=spawn N=nexus L=ley_line .=buildable +// Path: (0,5)→right→(3,5)→up→(3,2)→right→(7,2)→down→(7,7)→right→(9,7)→up→(9,4)→right→(13,4)→down→N(13,8) +const layout = [ + '...LL.....LL..', + '...LL.....LL..', + '...PPPPP......', + '...P...P...LL.', + '...P...P.PPPPP', + 'SPPP...P.P...P', + '.LL....P.P...P', + '.LL....PPPLL.P', + '..........LL.N', + '..............', +] + +export const WHISPERING_WOODS: LevelDef = { + id: 'whispering_woods', + name: 'Whispering Woods', + cols: 14, + rows: 10, + layout, + spawn: { x: 0, y: 5 }, + nexus: { x: 13, y: 8 }, + maxWaves: 20, + music: 'theme_forest', +} diff --git a/src/data/towerDefs.ts b/src/data/towerDefs.ts new file mode 100644 index 0000000..93ed019 --- /dev/null +++ b/src/data/towerDefs.ts @@ -0,0 +1,58 @@ +export interface TierDef { + damage: number + cooldown: number + range: number + cost: number +} + +export interface TowerDef { + id: string + nameRu: string + icon: string + description: string + damageType: 'physical' | 'magic' + tiers: TierDef[] +} + +export const TOWER_DEFS: Record = { + archer: { + id: 'archer', nameRu: 'Лучник', icon: '🏹', + description: 'Физ. урон по одной цели', + damageType: 'physical', + tiers: [ + { damage: 8, cooldown: 0.8, range: 180, cost: 50 }, + { damage: 14, cooldown: 0.65, range: 200, cost: 60 }, + { damage: 22, cooldown: 0.5, range: 220, cost: 90 }, + ], + }, + pyromancer: { + id: 'pyromancer', nameRu: 'Пиромант', icon: '🔥', + description: 'AoE огонь + поджог', + damageType: 'magic', + tiers: [ + { damage: 12, cooldown: 1.6, range: 150, cost: 80 }, + { damage: 20, cooldown: 1.3, range: 165, cost: 65 }, + { damage: 32, cooldown: 1.0, range: 180, cost: 100 }, + ], + }, + cryomancer: { + id: 'cryomancer', nameRu: 'Криомант', icon: '❄', + description: 'Маг. урон + замедление', + damageType: 'magic', + tiers: [ + { damage: 6, cooldown: 1.1, range: 160, cost: 90 }, + { damage: 10, cooldown: 0.9, range: 175, cost: 70 }, + { damage: 16, cooldown: 0.7, range: 190, cost: 110 }, + ], + }, + stormcaller: { + id: 'stormcaller', nameRu: 'Повелитель бурь', icon: '⚡', + description: 'Цепная молния по 3 целям', + damageType: 'magic', + tiers: [ + { damage: 18, cooldown: 2.0, range: 200, cost: 110 }, + { damage: 28, cooldown: 1.6, range: 220, cost: 80 }, + { damage: 42, cooldown: 1.2, range: 240, cost: 120 }, + ], + }, +} diff --git a/src/data/waves.ts b/src/data/waves.ts new file mode 100644 index 0000000..f9b09a0 --- /dev/null +++ b/src/data/waves.ts @@ -0,0 +1,94 @@ +export type EnemyType = 'goblin' | 'orc' | 'warg' | 'wraith' | 'troll' | 'golem' | 'necromancer' | 'dragon' | 'lich_king' + +export interface WaveDef { + enemies: EnemyType[] + interval: number +} + +function rep(type: EnemyType, n: number): EnemyType[] { + return Array(n).fill(type) +} + +export const WAVE_DEFS: WaveDef[] = [ + // Wave 1 — tutorial + { enemies: rep('goblin', 6), interval: 1.2 }, + // Wave 2 + { enemies: [...rep('goblin', 5), ...rep('warg', 3)], interval: 1.1 }, + // Wave 3 + { enemies: [...rep('goblin', 6), ...rep('orc', 2)], interval: 1.1 }, + // Wave 4 + { enemies: [...rep('warg', 5), ...rep('goblin', 4)], interval: 1.0 }, + // Wave 5 — первые призраки + { enemies: [...rep('goblin', 4), ...rep('orc', 2), ...rep('wraith', 3)], interval: 1.0 }, + // Wave 6 + { enemies: [...rep('wraith', 4), ...rep('goblin', 5), ...rep('orc', 2)], interval: 0.9 }, + // Wave 7 — первый тролль + { enemies: [...rep('troll', 2), ...rep('orc', 3), ...rep('warg', 4), ...rep('wraith', 2)], interval: 0.9 }, + // Wave 8 — первые големы + { enemies: [...rep('golem', 2), ...rep('orc', 4), ...rep('wraith', 3)], interval: 0.85 }, + // Wave 9 + { enemies: [...rep('troll', 2), ...rep('golem', 2), ...rep('goblin', 4), ...rep('wraith', 3)], interval: 0.85 }, + // Wave 10 — БОСС: Некромант + элита + { enemies: ['necromancer', ...rep('golem', 2), ...rep('troll', 2), ...rep('orc', 4), ...rep('wraith', 4)], interval: 0.8 }, + // Wave 11 + { enemies: [...rep('golem', 3), ...rep('wraith', 5), ...rep('troll', 2)], interval: 0.75 }, + // Wave 12 + { enemies: [...rep('necromancer', 1), ...rep('golem', 3), ...rep('orc', 5)], interval: 0.75 }, + // Wave 13 + { enemies: [...rep('troll', 4), ...rep('wraith', 5), ...rep('goblin', 6)], interval: 0.7 }, + // Wave 14 + { enemies: [...rep('golem', 4), ...rep('orc', 4), ...rep('wraith', 4), ...rep('warg', 4)], interval: 0.7 }, + // Wave 15 — Дракон + второй Некромант + большая орда + { enemies: ['dragon', 'necromancer', ...rep('troll', 3), ...rep('golem', 2), ...rep('wraith', 5), ...rep('orc', 4)], interval: 0.65 }, + // Wave 16 + { enemies: [...rep('golem', 4), ...rep('wraith', 6), ...rep('troll', 3), ...rep('goblin', 5)], interval: 0.6 }, + // Wave 17 — два дракона + { enemies: ['dragon', ...rep('necromancer', 1), ...rep('golem', 4), ...rep('wraith', 6)], interval: 0.6 }, + // Wave 18 + { enemies: [...rep('troll', 5), ...rep('golem', 5), ...rep('orc', 6), ...rep('warg', 6)], interval: 0.55 }, + // Wave 19 — финальная орда + { enemies: ['dragon', 'dragon', ...rep('golem', 4), ...rep('necromancer', 2), ...rep('wraith', 8)], interval: 0.55 }, + // Wave 20 — ФИНАЛ + { enemies: ['dragon', 'dragon', ...rep('golem', 5), ...rep('troll', 4), ...rep('necromancer', 2), ...rep('wraith', 6), ...rep('orc', 6)], interval: 0.5 }, +] + +export const OBSIDIAN_KEEP_WAVES: WaveDef[] = [ + // Wave 1 — heavy vanguard + { enemies: [...rep('golem', 3), ...rep('troll', 2), ...rep('wraith', 4)], interval: 0.9 }, + // Wave 2 — wraith + necromancer rush + { enemies: [...rep('wraith', 6), 'necromancer', ...rep('orc', 3)], interval: 0.8 }, + // Wave 3 — first dragon + { enemies: ['dragon', ...rep('golem', 3), ...rep('troll', 3), ...rep('wraith', 4)], interval: 0.75 }, + // Wave 4 — overwhelming horde + { enemies: [...rep('golem', 4), ...rep('necromancer', 2), ...rep('wraith', 6), ...rep('orc', 4)], interval: 0.7 }, + // Wave 5 — mid-boss checkpoint + { enemies: ['dragon', 'necromancer', ...rep('golem', 4), ...rep('wraith', 6), ...rep('troll', 3)], interval: 0.65 }, + // Wave 6 — twin dragons + { enemies: ['dragon', 'dragon', ...rep('necromancer', 2), ...rep('golem', 4), ...rep('wraith', 5)], interval: 0.6 }, + // Wave 7 — undead swarm + { enemies: [...rep('wraith', 10), ...rep('troll', 4), ...rep('golem', 4), 'necromancer'], interval: 0.55 }, + // Wave 8 — triple dragon assault + { enemies: ['dragon', 'dragon', 'dragon', ...rep('golem', 5), ...rep('necromancer', 2), ...rep('wraith', 6)], interval: 0.55 }, + // Wave 9 — pre-boss wave (everything) + { enemies: ['dragon', 'dragon', ...rep('necromancer', 3), ...rep('golem', 5), ...rep('troll', 4), ...rep('wraith', 8)], interval: 0.5 }, + // Wave 10 — LICH KING FINAL BOSS + { enemies: ['lich_king', ...rep('wraith', 6), ...rep('golem', 3), ...rep('necromancer', 2)], interval: 0.8 }, +] + +export function getWaveDef(waveIndex: number, levelId?: string): WaveDef { + if (levelId === 'obsidian_keep') { + const defs = OBSIDIAN_KEEP_WAVES + if (waveIndex <= defs.length) return defs[waveIndex - 1] + return defs[defs.length - 1] + } + if (waveIndex <= WAVE_DEFS.length) return WAVE_DEFS[waveIndex - 1] + const base = WAVE_DEFS[WAVE_DEFS.length - 1] + const extra = waveIndex - WAVE_DEFS.length + const enemies: EnemyType[] = [] + for (const e of base.enemies) { + enemies.push(e) + if (extra >= 2) enemies.push(e) + if (extra >= 4 && Math.random() < 0.5) enemies.push(e) + } + return { enemies, interval: Math.max(0.45, base.interval - extra * 0.04) } +} diff --git a/src/game/LevelScene.ts b/src/game/LevelScene.ts new file mode 100644 index 0000000..2ca03a5 --- /dev/null +++ b/src/game/LevelScene.ts @@ -0,0 +1,627 @@ +import { Container, Graphics } from 'pixi.js' +import { GridMap, TILE_SIZE, ISO_HALF_W, ISO_HALF_H } from './map/GridMap' +import { findPath, terminatePathWorker } from './map/Pathfinding' +import { MapRenderer } from './rendering/MapRenderer' +import { Camera } from './rendering/camera' +import { layers, getSize } from './rendering/PixiRoot' +import type { LevelDef } from '@/data/levels' +import { EntityManager } from './core/EntityManager' +import { movementSystem } from './systems/MovementSystem' +import { targetingSystem } from './systems/TargetingSystem' +import { attackSystem } from './systems/AttackSystem' +import { projectileSystem } from './systems/ProjectileSystem' +import { renderSystem } from './systems/RenderSystem' +import { deathSystem } from './systems/DeathSystem' +import { statusEffectSystem } from './systems/StatusEffectSystem' +import { heroSystem, heroAddXp, getHeroComp } from './systems/HeroSystem' +import { necromancerSystem } from './systems/NecromancerSystem' +import { lichKingSystem } from './systems/LichKingSystem' +import { createWaveState, startWave, waveSystem, type WaveState } from './systems/WaveSystem' +import { createArcherTower, ARCHER_COST } from './entities/towers/archer' +import { createPyromancer, PYROMANCER_COST } from './entities/towers/pyromancer' +import { createCryomancer, CRYO_COST } from './entities/towers/cryomancer' +import { createStormcaller, STORM_COST } from './entities/towers/stormcaller' +import { createArchmage } from './entities/hero/archmage' +import { eventBus } from './core/EventBus' +import { useGameStore } from '@/state/gameStore' +import { setWorldContainer, getWorldContainer } from './rendering/WorldContext' +import { TOWER_DEFS } from '@/data/towerDefs' +import type { TowerComp, AttackComp, TargetingComp, HealthComp } from './components' +import type { StatusEffectsComp } from './components/StatusEffect' +import { applyStatus } from './components/StatusEffect' +import { resolveDamage } from './combat/DamageResolver' +import { refreshHp } from './entities/enemies/updateHpBar' +import type { HeroComp } from './components/Hero' + +export type PlacementMode = 'none' | 'archer' | 'pyromancer' | 'cryomancer' | 'stormcaller' + +const _TOWER_GHOST_COLORS: Record = { + archer: 0xccaa44, + pyromancer: 0xff6622, + cryomancer: 0x44ccee, + stormcaller: 0xaa88ff, +} + +const LEY_LINE_DAMAGE_MULT = 1.20 +const LEY_LINE_RANGE_MULT = 1.12 + +export interface TowerInfo { + towerId: string + nameRu: string + icon: string + tier: number + maxTier: number + damage: number + cooldown: number + range: number + totalCost: number + upgradeCost: number | null + sellValue: number + leyLineBuff: boolean +} + +export class LevelScene { + readonly map: GridMap + readonly entities = new EntityManager() + private renderer: MapRenderer + private camera: Camera + private worldRoot: Container + private hoverGfx = new Graphics() + private selectionGfx = new Graphics() + private _destroyed = false + private _path: { x: number; y: number }[] = [] + private _screenPath: { x: number; y: number }[] = [] + private _detachCamera: (() => void) | null = null + private _waveState: WaveState = createWaveState() + private _placementMode: PlacementMode = 'none' + private _spellMode: string | null = null + private _maxWaves = 0 + + constructor(def: LevelDef) { + this.map = new GridMap(def.cols, def.rows) + this.map.loadFromLayout(def.layout) + this._maxWaves = useGameStore.getState().maxWaves + + this.worldRoot = new Container() + this.worldRoot.sortableChildren = true + layers!.terrain.addChild(this.worldRoot) + setWorldContainer(this.worldRoot) + + this._drawBackground() + + this.renderer = new MapRenderer() + this.worldRoot.addChild(this.renderer.container) + this.worldRoot.addChild(this.hoverGfx) + this.worldRoot.addChild(this.selectionGfx) + + const raw = getSize() + const width = raw.width > 0 ? raw.width : window.innerWidth + const height = raw.height > 0 ? raw.height : window.innerHeight + const mapW = this.map.isoMapWidth() + const mapH = this.map.isoMapHeight() + + this.camera = new Camera(this.worldRoot, { + minX: -mapW, maxX: width, + minY: -mapH, maxY: height, + minZoom: 0.5, maxZoom: 2.5, + }) + + this.worldRoot.x = Math.max(ISO_HALF_W, (width - mapW) / 2) + this.worldRoot.y = Math.max(ISO_HALF_H, (height - mapH) / 2) + ISO_HALF_H + this.camera.x = this.worldRoot.x + this.camera.y = this.worldRoot.y + + this.renderer.load(this.map) + this._calcPath(def.spawn, def.nexus) + this._setupEventListeners() + this._spawnHero(def) + } + + update(dt: number): void { + if (this._destroyed) return + const store = useGameStore.getState() + + this.camera.update(dt) + heroSystem(this.entities, dt) + + if (store.phase === 'combat') { + statusEffectSystem(this.entities, dt) + movementSystem(this.entities, dt) + necromancerSystem(this.entities, dt) + lichKingSystem(this.entities, this._screenPath, dt) + targetingSystem(this.entities) + attackSystem(this.entities, dt) + projectileSystem(this.entities, dt) + renderSystem(this.entities) + deathSystem(this.entities, this.map) + waveSystem(this._waveState, this.entities, this.map, this._screenPath, dt) + } else if (store.phase === 'build' && this._waveState.countdown > 0) { + this._waveState.countdown -= dt + if (this._waveState.countdown <= 0 && store.wave < this._maxWaves) { + this._waveState.countdown = 0 + this.startWave() + } + } + } + + startWave(): void { + const store = useGameStore.getState() + if (store.wave >= this._maxWaves) return + store.setSelectedTowerTile(null) + const nextWave = store.wave + 1 + store.setWave(nextWave) + store.setPhase('combat') + startWave(this._waveState, nextWave) + } + + setPlacementMode(mode: PlacementMode): void { + this._placementMode = mode + this._spellMode = null + if (mode !== 'none') { + useGameStore.getState().setSelectedTowerTile(null) + this.selectionGfx.clear() + } + } + + setSpellMode(spellId: string | null): void { + this._spellMode = spellId + if (spellId !== null) { + this._placementMode = 'none' + useGameStore.getState().setSelectedTowerTile(null) + this.selectionGfx.clear() + } + } + + getSpellMode(): string | null { return this._spellMode } + + getWaveState(): WaveState { return this._waveState } + + getHeroComp(): HeroComp | null { return getHeroComp(this.entities) } + + getTowerInfo(tx: number, ty: number): TowerInfo | null { + const tile = this.map.get(tx, ty) + if (!tile?.towerEntityId) return null + const entity = this.entities.get(tile.towerEntityId) + if (!entity) return null + const tc = entity.towerComp as TowerComp + const atk = entity.attack as AttackComp + const tgt = entity.targeting as TargetingComp + const def = TOWER_DEFS[tc.towerId] + return { + towerId: tc.towerId, + nameRu: def?.nameRu ?? tc.towerId, + icon: def?.icon ?? '🏰', + tier: tc.tier, + maxTier: def?.tiers.length ?? 1, + damage: atk?.damage ?? 0, + cooldown: atk?.cooldown ?? 0, + range: tgt?.range ?? 0, + totalCost: tc.totalCost, + upgradeCost: def && tc.tier < def.tiers.length ? def.tiers[tc.tier].cost : null, + sellValue: Math.floor(tc.totalCost * 0.6), + leyLineBuff: tc.leyLineBuff, + } + } + + upgradeTower(tx: number, ty: number): boolean { + const tile = this.map.get(tx, ty) + if (!tile?.towerEntityId) return false + const entity = this.entities.get(tile.towerEntityId) + if (!entity) return false + const tc = entity.towerComp as TowerComp + const def = TOWER_DEFS[tc.towerId] + if (!def || tc.tier >= def.tiers.length) return false + const nextTier = def.tiers[tc.tier] + if (!useGameStore.getState().spendGold(nextTier.cost)) return false + const atk = entity.attack as AttackComp + const tgt = entity.targeting as TargetingComp + if (atk) { atk.damage = nextTier.damage; atk.cooldown = nextTier.cooldown } + if (tgt) { tgt.range = nextTier.range } + tc.tier++; tc.totalCost += nextTier.cost + if (tc.leyLineBuff) { + if (atk) atk.damage = Math.round(atk.damage * LEY_LINE_DAMAGE_MULT) + if (tgt) tgt.range = Math.round(tgt.range * LEY_LINE_RANGE_MULT) + } + return true + } + + sellTower(tx: number, ty: number): void { + const tile = this.map.get(tx, ty) + if (!tile?.towerEntityId) return + const entity = this.entities.get(tile.towerEntityId) + if (!entity) return + const tc = entity.towerComp as TowerComp + useGameStore.getState().addGold(Math.floor(tc.totalCost * 0.6)) + useGameStore.getState().setSelectedTowerTile(null) + entity.tags.add('dead') + this.selectionGfx.clear() + } + + castSpell(spellId: string, worldX: number, worldY: number): boolean { + const heroComp = getHeroComp(this.entities) + if (!heroComp) return false + const spell = heroComp.spells.find((s) => s.id === spellId) + if (!spell || spell.timer > 0) return false + if (!useGameStore.getState().spendMana(spell.manaCost)) return false + + spell.timer = spell.cooldown + + switch (spellId) { + case 'fireball': this._castFireball(worldX, worldY, spell.damage, spell.radius); break + case 'blizzard': this._castBlizzard(worldX, worldY, spell.radius); break + case 'blink': this._castBlink(worldX, worldY); break + case 'timewarp': this._castTimeWarp(); break + } + + this._spellMode = null + return true + } + + private _castFireball(cx: number, cy: number, damage: number, radius: number): void { + for (const enemy of this.entities.withTag('enemy')) { + if (enemy.tags.has('dead')) continue + const et = enemy.transform as { x: number; y: number } | undefined + if (!et) continue + if ((et.x - cx) ** 2 + (et.y - cy) ** 2 > radius * radius) continue + const hp = enemy.health as HealthComp + if (hp) { + const dmg = resolveDamage(damage, 'magic', hp) + hp.current -= dmg + refreshHp(enemy) + if (hp.current <= 0) { + enemy.tags.add('dead') + eventBus.emit('enemy:died', { id: enemy.id, gold: (enemy.loot as { gold: number } | undefined)?.gold ?? 0, essence: 0 }) + } + } + const se = enemy.statusEffects as StatusEffectsComp | undefined + if (se) applyStatus(se, { type: 'burn', duration: 4, strength: 6 }) + } + this._fxCircle(cx, cy, radius, 0xff4400, 0xff8800) + } + + private _castBlizzard(cx: number, cy: number, radius: number): void { + for (const enemy of this.entities.withTag('enemy')) { + if (enemy.tags.has('dead')) continue + const et = enemy.transform as { x: number; y: number } | undefined + if (!et) continue + if ((et.x - cx) ** 2 + (et.y - cy) ** 2 > radius * radius) continue + const se = enemy.statusEffects as StatusEffectsComp | undefined + if (se) applyStatus(se, { type: 'slow', duration: 4, strength: 0.7 }) + } + this._fxCircle(cx, cy, radius, 0x6ecbd5, 0xaaeeff) + } + + private _castBlink(wx: number, wy: number): void { + const heroes = this.entities.withTag('hero') + if (heroes.length === 0) return + const hero = heroes[0] + const t = hero.transform as { x: number; y: number } + const h = hero.hero as HeroComp + const oldX = t.x; const oldY = t.y + t.x = wx; t.y = wy + h.targetX = null; h.targetY = null + this._fxBlink(oldX, oldY, wx, wy) + } + + private _castTimeWarp(): void { + for (const enemy of this.entities.withTag('enemy')) { + if (enemy.tags.has('dead')) continue + const se = enemy.statusEffects as StatusEffectsComp | undefined + if (se) applyStatus(se, { type: 'slow', duration: 5, strength: 0.65 }) + } + this._fxTimeWarp() + } + + private _fxCircle(cx: number, cy: number, r: number, fill: number, stroke: number): void { + let world: Container | null = null + try { world = getWorldContainer() } catch { return } + const gfx = new Graphics() + gfx.circle(cx, cy, r) + gfx.fill({ color: fill, alpha: 0.3 }) + gfx.circle(cx, cy, r) + gfx.stroke({ color: stroke, width: 3, alpha: 0.8 }) + world.addChild(gfx) + let life = 0.5 + const tick = () => { + life -= 1 / 60; gfx.alpha = Math.max(0, life / 0.5) + gfx.scale.set(1 + (0.5 - life) * 0.4) + if (life <= 0) gfx.destroy(); else requestAnimationFrame(tick) + } + requestAnimationFrame(tick) + } + + private _fxBlink(x1: number, y1: number, x2: number, y2: number): void { + let world: Container | null = null + try { world = getWorldContainer() } catch { return } + const gfx = new Graphics() + gfx.circle(x1, y1, 20) + gfx.fill({ color: 0x9966ff, alpha: 0.6 }) + gfx.circle(x2, y2, 20) + gfx.fill({ color: 0xcc99ff, alpha: 0.8 }) + world.addChild(gfx) + let life = 0.4 + const tick = () => { + life -= 1 / 60; gfx.alpha = Math.max(0, life / 0.4) + if (life <= 0) gfx.destroy(); else requestAnimationFrame(tick) + } + requestAnimationFrame(tick) + } + + private _fxTimeWarp(): void { + let world: Container | null = null + try { world = getWorldContainer() } catch { return } + const gfx = new Graphics() + // Full-screen tint ring + for (let i = 0; i < 4; i++) { + gfx.circle(0, 0, 200 + i * 120) + gfx.stroke({ color: 0x00ccff, width: 3 - i * 0.5, alpha: 0.4 - i * 0.08 }) + } + world.addChild(gfx) + let life = 0.6 + const tick = () => { + life -= 1 / 60; gfx.alpha = Math.max(0, life / 0.6) + if (life <= 0) gfx.destroy(); else requestAnimationFrame(tick) + } + requestAnimationFrame(tick) + } + + attachInput(canvas: HTMLElement): void { + this._detachCamera = this.camera.attachPointerEvents(canvas) + canvas.addEventListener('pointermove', this._onPointerMove) + canvas.addEventListener('pointerdown', this._onPointerDown) + canvas.addEventListener('contextmenu', (e) => e.preventDefault()) + } + + detachInput(canvas: HTMLElement): void { + this._detachCamera?.() + canvas.removeEventListener('pointermove', this._onPointerMove) + canvas.removeEventListener('pointerdown', this._onPointerDown) + } + + private _onPointerMove = (e: PointerEvent): void => { + if (this._destroyed) return + const canvas = e.currentTarget as HTMLElement + const rect = canvas.getBoundingClientRect() + const world = this.camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top) + const { x: tx, y: ty } = this.map.screenToTile(world.x, world.y) + this._drawHover(tx, ty) + } + + private _onPointerDown = (e: PointerEvent): void => { + if (this._destroyed) return + if (e.button === 2) { + // Right click: cancel modes + this._placementMode = 'none' + this._spellMode = null + return + } + if (e.button !== 0) return + + const canvas = e.currentTarget as HTMLElement + const rect = canvas.getBoundingClientRect() + const world = this.camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top) + const wx = world.x; const wy = world.y + const { x: tx, y: ty } = this.map.screenToTile(wx, wy) + + if (this._placementMode !== 'none') { + this._placeTower(tx, ty, this._placementMode) + return + } + + if (this._spellMode !== null) { + const spell = getHeroComp(this.entities)?.spells.find((s) => s.id === this._spellMode) + if (spell?.targetMode === 'point') { + this.castSpell(this._spellMode, wx, wy) + } + return + } + + // Tower selection or hero movement + const tile = this.map.get(tx, ty) + if (tile?.towerEntityId) { + useGameStore.getState().setSelectedTowerTile({ x: tx, y: ty }) + this._drawSelection(tx, ty) + } else { + useGameStore.getState().setSelectedTowerTile(null) + this.selectionGfx.clear() + // Move hero to clicked world position + this._moveHero(wx, wy) + } + } + + private _moveHero(wx: number, wy: number): void { + const heroes = this.entities.withTag('hero') + if (heroes.length === 0) return + const h = heroes[0].hero as HeroComp + if (h) { h.targetX = wx; h.targetY = wy } + } + + private _hasAdjacentLeyLine(tx: number, ty: number): boolean { + const dirs = [[-1,0],[1,0],[0,-1],[0,1],[-1,-1],[1,-1],[-1,1],[1,1]] + for (const [dx, dy] of dirs) { + if (this.map.get(tx + dx, ty + dy)?.type === 'ley_line') return true + } + return false + } + + private _applyLeyLineBuff(entity: ReturnType): void { + const atk = entity.attack as AttackComp + const tgt = entity.targeting as TargetingComp + if (atk) atk.damage = Math.round(atk.damage * LEY_LINE_DAMAGE_MULT) + if (tgt) tgt.range = Math.round(tgt.range * LEY_LINE_RANGE_MULT) + const tc = entity.towerComp as TowerComp + if (tc) tc.leyLineBuff = true + // Blue glow dot under tower + const r = entity.render as import('./components').RenderComp | undefined + if (r?.container) { + const gfx = new Graphics() + gfx.circle(0, 0, ISO_HALF_W * 0.85) + gfx.stroke({ color: 0x44bbff, width: 2.5, alpha: 0.7 }) + gfx.circle(0, 0, 5) + gfx.fill({ color: 0x44bbff, alpha: 0.8 }) + r.container.addChildAt(gfx, 0) + } + } + + private _placeTower(tx: number, ty: number, mode: PlacementMode): void { + if (!this.map.isBuildable(tx, ty)) return + const store = useGameStore.getState() + const costs: Record = { + none: 0, archer: ARCHER_COST, pyromancer: PYROMANCER_COST, + cryomancer: CRYO_COST, stormcaller: STORM_COST, + } + if (!store.spendGold(costs[mode])) return + const tile = this.map.get(tx, ty)! + const entity = this.entities.create() + const { x: wxp, y: wyp } = this.map.tileCenter(tx, ty) + switch (mode) { + case 'archer': createArcherTower(entity, tx, ty, wxp, wyp); break + case 'pyromancer': createPyromancer(entity, tx, ty, wxp, wyp); break + case 'cryomancer': createCryomancer(entity, tx, ty, wxp, wyp); break + case 'stormcaller': createStormcaller(entity, tx, ty, wxp, wyp); break + } + if (this._hasAdjacentLeyLine(tx, ty) || tile.type === 'ley_line') { + this._applyLeyLineBuff(entity) + } else { + const tc = entity.towerComp as TowerComp + if (tc) tc.leyLineBuff = false + } + tile.towerEntityId = entity.id + this._placementMode = 'none' + } + + private _drawHover(tx: number, ty: number): void { + this.hoverGfx.clear() + if (!this.map.get(tx, ty)) return + const { x: cx, y: cy } = this.map.tileCenter(tx, ty) + const hw = ISO_HALF_W, hh = ISO_HALF_H + const inMode = this._placementMode !== 'none' || this._spellMode !== null + const canBuild = this._placementMode !== 'none' && this.map.isBuildable(tx, ty) + const color = canBuild ? 0x44ff88 : inMode ? 0xff4444 : 0xffffff + + // Diamond outline + this.hoverGfx.poly([cx, cy - hh, cx + hw, cy, cx, cy + hh, cx - hw, cy]) + this.hoverGfx.stroke({ color, width: 2, alpha: 0.7 }) + this.hoverGfx.poly([cx, cy - hh, cx + hw, cy, cx, cy + hh, cx - hw, cy]) + this.hoverGfx.fill({ color, alpha: 0.1 }) + + if (canBuild && this._placementMode !== 'none') { + const def = TOWER_DEFS[this._placementMode] + const range = def?.tiers[0].range ?? 120 + const hasLey = this._hasAdjacentLeyLine(tx, ty) + const boostedRange = hasLey ? Math.round(range * 1.12) : range + + const rangeColor = _TOWER_GHOST_COLORS[this._placementMode] ?? 0xffd700 + this.hoverGfx.circle(cx, cy, boostedRange) + this.hoverGfx.fill({ color: rangeColor, alpha: 0.06 }) + this.hoverGfx.circle(cx, cy, boostedRange) + this.hoverGfx.stroke({ color: rangeColor, width: 1.5, alpha: 0.4 }) + + // Ghost tower diamond + this.hoverGfx.poly([cx, cy - hh * 0.7, cx + hw * 0.7, cy, cx, cy + hh * 0.7, cx - hw * 0.7, cy]) + this.hoverGfx.fill({ color: rangeColor, alpha: 0.25 }) + this.hoverGfx.poly([cx, cy - hh * 0.7, cx + hw * 0.7, cy, cx, cy + hh * 0.7, cx - hw * 0.7, cy]) + this.hoverGfx.stroke({ color: rangeColor, width: 2, alpha: 0.7 }) + + if (hasLey) { + this.hoverGfx.poly([cx, cy - hh, cx + hw, cy, cx, cy + hh, cx - hw, cy]) + this.hoverGfx.stroke({ color: 0x44bbff, width: 2.5, alpha: 0.9 }) + } + } + } + + private _drawSelection(tx: number, ty: number): void { + this.selectionGfx.clear() + const { x: cx, y: cy } = this.map.tileCenter(tx, ty) + const hw = ISO_HALF_W, hh = ISO_HALF_H + this.selectionGfx.poly([cx, cy - hh, cx + hw, cy, cx, cy + hh, cx - hw, cy]) + this.selectionGfx.stroke({ color: 0xffd700, width: 2.5, alpha: 0.95 }) + this.selectionGfx.poly([cx, cy - hh, cx + hw, cy, cx, cy + hh, cx - hw, cy]) + this.selectionGfx.fill({ color: 0xffd700, alpha: 0.12 }) + } + + private _drawBackground(): void { + const raw = getSize() + const width = raw.width > 0 ? raw.width : window.innerWidth + const height = raw.height > 0 ? raw.height : window.innerHeight + const bg = new Graphics() + bg.rect(-200, -200, width + 400, height + 400) + bg.fill({ color: 0x0a1a0a }) + const step = TILE_SIZE * 4 + for (let x = 0; x < width + 400; x += step) { + bg.moveTo(x - 200, -200); bg.lineTo(x - 200, height + 200) + bg.stroke({ color: 0x1a2a1a, width: 1, alpha: 0.3 }) + } + for (let y = 0; y < height + 400; y += step) { + bg.moveTo(-200, y - 200); bg.lineTo(width + 200, y - 200) + bg.stroke({ color: 0x1a2a1a, width: 1, alpha: 0.3 }) + } + layers!.bg.addChild(bg) + } + + private _spawnHero(def: LevelDef): void { + const startTileX = Math.min(def.cols - 2, 10) + const startTileY = 1 + const { x: sx, y: sy } = this.map.tileCenter(startTileX, startTileY) + const entity = this.entities.create() + createArchmage(entity, sx, sy) + } + + private async _calcPath( + spawn: { x: number; y: number }, + nexus: { x: number; y: number }, + ): Promise { + const path = await findPath(this.map, spawn, nexus) + if (path) { + this._path = path + this._screenPath = path.map((p) => this.map.tileCenter(p.x, p.y)) + this.renderer.showPath(path) + } + } + + private _setupEventListeners(): void { + eventBus.on<{ id: number; gold: number; essence: number }>('enemy:died', (data) => { + if (this._destroyed) return + useGameStore.getState().addGold(data.gold) + heroAddXp(this.entities, 5) + }) + + eventBus.on<{ id: number; damage: number }>('enemy:reached_end', () => { + if (this._destroyed) return + const store = useGameStore.getState() + store.damageNexus(1) + this.camera.shake(10, 0.4) + if (store.nexusHp <= 0) { store.setWon(false); store.setScreen('gameover') } + }) + + eventBus.on<{ amount: number }>('lich:nexus_damage', (data) => { + if (this._destroyed) return + const store = useGameStore.getState() + store.damageNexus(data.amount) + this.camera.shake(8, 0.5) + if (store.nexusHp <= 0) { store.setWon(false); store.setScreen('gameover') } + }) + + eventBus.on<{ waveIndex: number }>('wave:end', (ev) => { + if (this._destroyed) return + const store = useGameStore.getState() + store.setPhase('build') + store.addMana(20) + heroAddXp(this.entities, 50) + if (ev.waveIndex >= this._maxWaves) { store.setWon(true); store.setScreen('gameover') } + }) + } + + getPath(): { x: number; y: number }[] { return this._path } + getScreenPath(): { x: number; y: number }[] { return this._screenPath } + + destroy(): void { + this._destroyed = true + setWorldContainer(null) + this._detachCamera?.() + this.renderer.destroy() + this.worldRoot.destroy({ children: true }) + this.entities.clear() + terminatePathWorker() + } +} diff --git a/src/game/combat/DamageResolver.ts b/src/game/combat/DamageResolver.ts new file mode 100644 index 0000000..1d750d0 --- /dev/null +++ b/src/game/combat/DamageResolver.ts @@ -0,0 +1,17 @@ +import type { DamageType, HealthComp } from '@/game/components' + +export function resolveDamage( + raw: number, + type: DamageType, + target: HealthComp, +): number { + if (type === 'true') return Math.max(1, raw) + if (type === 'physical') { + if (target.physicalImmune) return 0 + const reduction = target.armor / (target.armor + 20) + return Math.max(1, Math.round(raw * (1 - reduction))) + } + // magic + const reduction = target.magicResist / (target.magicResist + 20) + return Math.max(1, Math.round(raw * (1 - reduction))) +} diff --git a/src/game/components/Hero.ts b/src/game/components/Hero.ts new file mode 100644 index 0000000..7b47858 --- /dev/null +++ b/src/game/components/Hero.ts @@ -0,0 +1,73 @@ +export interface SpellDef { + id: string + nameRu: string + icon: string + manaCost: number + cooldown: number + timer: number + damage: number + radius: number + targetMode: 'point' | 'global' + hotkey: string +} + +export interface HeroComp { + targetX: number | null + targetY: number | null + speed: number + xp: number + level: number + spells: SpellDef[] + bobTime: number +} + +export const HERO_SPELLS: Omit[] = [ + { + id: 'fireball', + nameRu: 'Огненный шар', + icon: '🔥', + manaCost: 25, + cooldown: 8, + damage: 40, + radius: 70, + targetMode: 'point', + hotkey: 'Q', + }, + { + id: 'blizzard', + nameRu: 'Буран', + icon: '❄', + manaCost: 30, + cooldown: 12, + damage: 0, + radius: 90, + targetMode: 'point', + hotkey: 'W', + }, + { + id: 'blink', + nameRu: 'Мерцание', + icon: '✦', + manaCost: 20, + cooldown: 5, + damage: 0, + radius: 0, + targetMode: 'point', + hotkey: 'E', + }, + { + id: 'timewarp', + nameRu: 'Замедление', + icon: '⌛', + manaCost: 45, + cooldown: 25, + damage: 0, + radius: 0, + targetMode: 'global', + hotkey: 'R', + }, +] + +export function makeSpells(): SpellDef[] { + return HERO_SPELLS.map((s) => ({ ...s, timer: 0 })) +} diff --git a/src/game/components/StatusEffect.ts b/src/game/components/StatusEffect.ts new file mode 100644 index 0000000..4c78b36 --- /dev/null +++ b/src/game/components/StatusEffect.ts @@ -0,0 +1,32 @@ +export type StatusEffectType = 'burn' | 'slow' | 'freeze' + +export interface StatusEffect { + type: StatusEffectType + duration: number // remaining seconds + strength: number // burn: dmg/sec | slow/freeze: 0..1 multiplier reduction + tickTimer: number // for burn DoT interval +} + +export interface StatusEffectsComp { + effects: StatusEffect[] +} + +export function getSlowMultiplier(comp: StatusEffectsComp): number { + let mult = 1 + for (const e of comp.effects) { + if (e.type === 'slow' || e.type === 'freeze') { + mult = Math.min(mult, 1 - e.strength) + } + } + return Math.max(0.1, mult) +} + +export function applyStatus(comp: StatusEffectsComp, effect: Omit): void { + const existing = comp.effects.find((e) => e.type === effect.type) + if (existing) { + existing.duration = Math.max(existing.duration, effect.duration) + existing.strength = Math.max(existing.strength, effect.strength) + } else { + comp.effects.push({ ...effect, tickTimer: 0 }) + } +} diff --git a/src/game/components/index.ts b/src/game/components/index.ts new file mode 100644 index 0000000..d966ba1 --- /dev/null +++ b/src/game/components/index.ts @@ -0,0 +1,73 @@ +export type DamageType = 'physical' | 'magic' | 'true' +export type TargetMode = 'first' | 'last' | 'closest' | 'strongest' +export type Team = 'player' | 'enemy' + +export interface TransformComp { + x: number + y: number + rotation: number +} + +export interface HealthComp { + current: number + max: number + armor: number + magicResist: number + physicalImmune?: boolean + regenRate?: number +} + +export interface MovementComp { + speed: number + path: { x: number; y: number }[] + pathIndex: number + distanceTravelled: number +} + +export interface TargetingComp { + range: number + mode: TargetMode + targetId: number | null +} + +export interface AttackComp { + damage: number + damageType: DamageType + cooldown: number + timer: number + projectileType: string +} + +export interface RenderComp { + container: import('pixi.js').Container + hpBar: import('pixi.js').Graphics | null + label: import('pixi.js').Text | null +} + +export interface TeamComp { + team: Team +} + +export interface LootComp { + gold: number + essence: number +} + +export interface ProjectileComp { + speed: number + targetId: number + damage: number + damageType: DamageType + ownerId: number + hitRadius: number + projectileType: string +} + +export interface TowerComp { + tileX: number + tileY: number + tier: number + towerId: string + totalCost: number + leyLineBuff: boolean +} diff --git a/src/game/core/EntityManager.ts b/src/game/core/EntityManager.ts new file mode 100644 index 0000000..057c930 --- /dev/null +++ b/src/game/core/EntityManager.ts @@ -0,0 +1,46 @@ +let _nextId = 1 + +export interface Entity { + id: number + tags: Set + [key: string]: unknown +} + +export class EntityManager { + private entities = new Map() + + create(tags: string[] = []): Entity { + const entity: Entity = { id: _nextId++, tags: new Set(tags) } + this.entities.set(entity.id, entity) + return entity + } + + get(id: number): Entity | undefined { + return this.entities.get(id) + } + + destroy(id: number): void { + this.entities.delete(id) + } + + withTag(tag: string): Entity[] { + const result: Entity[] = [] + for (const e of this.entities.values()) { + if (e.tags.has(tag)) result.push(e) + } + return result + } + + all(): Entity[] { + return [...this.entities.values()] + } + + clear(): void { + this.entities.clear() + _nextId = 1 + } + + get count() { + return this.entities.size + } +} diff --git a/src/game/core/EventBus.ts b/src/game/core/EventBus.ts new file mode 100644 index 0000000..7e6c76d --- /dev/null +++ b/src/game/core/EventBus.ts @@ -0,0 +1,36 @@ +type Handler = (data: T) => void + +class EventBus { + private listeners = new Map>() + + on(event: string, handler: Handler): () => void { + if (!this.listeners.has(event)) this.listeners.set(event, new Set()) + this.listeners.get(event)!.add(handler as Handler) + return () => this.off(event, handler) + } + + off(event: string, handler: Handler): void { + this.listeners.get(event)?.delete(handler as Handler) + } + + emit(event: string, data?: T): void { + this.listeners.get(event)?.forEach((h) => h(data as unknown)) + } + + clear(): void { + this.listeners.clear() + } +} + +export const eventBus = new EventBus() + +export type GameEvent = + | { type: 'enemy:died'; id: number; gold: number; essence: number } + | { type: 'enemy:reached_end'; id: number; damage: number } + | { type: 'tower:placed'; tileX: number; tileY: number } + | { type: 'wave:start'; waveIndex: number } + | { type: 'wave:end'; waveIndex: number } + | { type: 'game:over'; won: boolean } + | { type: 'gold:change'; amount: number } + | { type: 'mana:change'; amount: number } + | { type: 'nexus:damage'; hp: number; maxHp: number } diff --git a/src/game/core/GameEngine.ts b/src/game/core/GameEngine.ts new file mode 100644 index 0000000..6b8709a --- /dev/null +++ b/src/game/core/GameEngine.ts @@ -0,0 +1,114 @@ +import { Application, Text, TextStyle } from 'pixi.js' +import { Time } from './Time' +import { EntityManager } from './EntityManager' +import { eventBus } from './EventBus' +import { getApp, layers } from '@/game/rendering/PixiRoot' +import { LevelScene } from '@/game/LevelScene' +import type { LevelDef } from '@/data/levels' + +export class GameEngine { + private readonly _app: Application + readonly entities = new EntityManager() + private _running = false + private _lastTime = 0 + private _frameCount = 0 + private _fpsTime = 0 + private _fpsText: Text | null = null + private _showFps = false + private _scene: LevelScene | null = null + private _canvas: HTMLElement | null = null + + constructor(app: Application) { + this._app = app + } + + loadLevel(def: LevelDef, canvas: HTMLElement): void { + this._scene?.destroy() + this._canvas = canvas + this._scene = new LevelScene(def) + this._scene.attachInput(canvas) + } + + getScene(): LevelScene | null { + return this._scene + } + + start(): void { + this._running = true + this._lastTime = performance.now() + this._fpsTime = performance.now() + this._app.ticker.add(this._tick, this) + this._setupFpsCounter() + } + + stop(): void { + this._running = false + this._app.ticker.remove(this._tick, this) + } + + setShowFps(show: boolean): void { + this._showFps = show + if (this._fpsText) this._fpsText.visible = show + } + + private _tick(): void { + if (!this._running) return + + const now = performance.now() + Time.delta = Math.min((now - this._lastTime) / 1000, 0.05) + Time.elapsed += Time.delta + this._lastTime = now + + this._scene?.update(Time.delta) + this._updateFps(now) + } + + private _setupFpsCounter(): void { + const style = new TextStyle({ + fill: 'rgba(201, 161, 74, 0.7)', + fontSize: 12, + fontFamily: 'monospace', + }) + this._fpsText = new Text({ text: 'FPS: --', style }) + this._fpsText.visible = this._showFps + this._fpsText.x = 8 + this._fpsText.y = 8 + this._fpsText.zIndex = 9999 + layers?.ui.addChild(this._fpsText) + } + + private _updateFps(now: number): void { + this._frameCount++ + if (now - this._fpsTime >= 500) { + const fps = Math.round((this._frameCount * 1000) / (now - this._fpsTime)) + if (this._fpsText) this._fpsText.text = `FPS: ${fps}` + this._frameCount = 0 + this._fpsTime = now + } + } + + destroy(): void { + if (this._canvas && this._scene) { + this._scene.detachInput(this._canvas) + } + this._scene?.destroy() + this._scene = null + this.stop() + this.entities.clear() + eventBus.clear() + this._fpsText?.destroy() + this._fpsText = null + } +} + +let _engine: GameEngine | null = null + +export function createEngine(): GameEngine { + _engine?.destroy() + _engine = new GameEngine(getApp()) + return _engine +} + +export function getEngine(): GameEngine | null { + return _engine +} diff --git a/src/game/core/Time.ts b/src/game/core/Time.ts new file mode 100644 index 0000000..a93a8c0 --- /dev/null +++ b/src/game/core/Time.ts @@ -0,0 +1,9 @@ +export const Time = { + delta: 0, + elapsed: 0, + scale: 1, + + get scaledDelta() { + return Time.delta * Time.scale + }, +} diff --git a/src/game/entities/enemies/dragon.ts b/src/game/entities/enemies/dragon.ts new file mode 100644 index 0000000..8438308 --- /dev/null +++ b/src/game/entities/enemies/dragon.ts @@ -0,0 +1,156 @@ +import { Container, Graphics } from 'pixi.js' +import type { Entity } from '@/game/core/EntityManager' +import type { TransformComp, HealthComp, MovementComp, TeamComp, LootComp, RenderComp } from '@/game/components' +import type { StatusEffectsComp } from '@/game/components/StatusEffect' +import { getWorldContainer } from '@/game/rendering/WorldContext' + +const HP_W = 54 +const HP_H = 6 +const HP_Y = -60 + +export function createDragon( + entity: Entity, + flyPath: { x: number; y: number }[], + sx: number, + sy: number, +): void { + const container = new Container() + + // Flying shadow on ground (large, blurry-ish) + const groundShadow = new Graphics() + groundShadow.ellipse(0, 6, 36, 14) + groundShadow.fill({ color: 0x000000, alpha: 0.28 }) + + // Wing glow/aura + const glow = new Graphics() + glow.ellipse(0, -20, 42, 18) + glow.fill({ color: 0x220000, alpha: 0.35 }) + + // Left wing + const wings = new Graphics() + wings.poly([-10, -24, -44, -48, -36, -18, -10, -14]) + wings.fill({ color: 0x660000 }) + wings.stroke({ color: 0x882222, width: 1.5 }) + // Wing membrane veins left + wings.moveTo(-10, -24); wings.lineTo(-44, -48) + wings.stroke({ color: 0x993333, width: 1, alpha: 0.7 }) + wings.moveTo(-10, -24); wings.lineTo(-38, -30) + wings.stroke({ color: 0x993333, width: 0.8, alpha: 0.5 }) + wings.moveTo(-10, -20); wings.lineTo(-34, -20) + wings.stroke({ color: 0x993333, width: 0.6, alpha: 0.4 }) + + // Right wing + wings.poly([10, -24, 44, -48, 36, -18, 10, -14]) + wings.fill({ color: 0x660000 }) + wings.stroke({ color: 0x882222, width: 1.5 }) + wings.moveTo(10, -24); wings.lineTo(44, -48) + wings.stroke({ color: 0x993333, width: 1, alpha: 0.7 }) + wings.moveTo(10, -24); wings.lineTo(38, -30) + wings.stroke({ color: 0x993333, width: 0.8, alpha: 0.5 }) + wings.moveTo(10, -20); wings.lineTo(34, -20) + wings.stroke({ color: 0x993333, width: 0.6, alpha: 0.4 }) + + // Tail + const tail = new Graphics() + tail.poly([0, -10, -6, 4, -2, 16, 4, 20, 6, 10, 2, 0]) + tail.fill({ color: 0x881111 }) + tail.stroke({ color: 0xcc2222, width: 1 }) + + // Main body — elongated + const body = new Graphics() + body.ellipse(0, -24, 16, 24) + body.fill({ color: 0x881111 }) + body.ellipse(0, -24, 16, 24) + body.stroke({ color: 0xcc2222, width: 2 }) + // Scales row down body + for (let i = -3; i <= 3; i++) { + body.circle(i * 4, -24 + i * 5, 3.5) + body.fill({ color: 0x660000, alpha: 0.65 }) + } + // Belly lighter strip + body.ellipse(0, -22, 8, 18) + body.fill({ color: 0xaa2222, alpha: 0.5 }) + + // Neck + const neck = new Graphics() + neck.poly([-6, -44, 6, -44, 4, -54, -4, -54]) + neck.fill({ color: 0x881111 }) + + // Head + const head = new Graphics() + head.ellipse(0, -58, 12, 10) + head.fill({ color: 0x881111 }) + head.stroke({ color: 0xcc2222, width: 1.5 }) + // Snout + head.ellipse(0, -52, 8, 6) + head.fill({ color: 0x771111 }) + // Nostrils + head.circle(-3, -51, 1.5) + head.fill({ color: 0x440000 }) + head.circle(3, -51, 1.5) + head.fill({ color: 0x440000 }) + // Eyes — large glowing + head.circle(-5, -60, 4) + head.fill({ color: 0xff6600 }) + head.circle(-5, -60, 2.5) + head.fill({ color: 0xffaa00 }) + head.circle(-5, -60, 1.2) + head.fill({ color: 0xffffff, alpha: 0.8 }) + head.circle(5, -60, 4) + head.fill({ color: 0xff6600 }) + head.circle(5, -60, 2.5) + head.fill({ color: 0xffaa00 }) + head.circle(5, -60, 1.2) + head.fill({ color: 0xffffff, alpha: 0.8 }) + // Horns + head.poly([-4, -64, -8, -76, -1, -64]) + head.fill({ color: 0x554400 }) + head.poly([4, -64, 8, -76, 1, -64]) + head.fill({ color: 0x554400 }) + // Crest spines + head.poly([0, -68, -3, -72, 3, -72]) + head.fill({ color: 0xaa2222, alpha: 0.8 }) + + const hpBg = new Graphics() + hpBg.rect(-HP_W / 2, HP_Y, HP_W, HP_H) + hpBg.fill({ color: 0x330000 }) + const hpBar = new Graphics() + hpBar.rect(-HP_W / 2, HP_Y, HP_W, HP_H) + hpBar.fill({ color: 0xff2200 }) + + container.addChild(groundShadow, glow, wings, tail, body, neck, head, hpBg, hpBar) + container.x = sx + container.y = sy + container.zIndex = 50 + getWorldContainer().addChild(container) + + const transform: TransformComp = { x: sx, y: sy, rotation: 0 } + const health: HealthComp = { current: 450, max: 450, armor: 3, magicResist: 3 } + const movement: MovementComp = { + speed: 70, + path: [...flyPath], + pathIndex: 0, + distanceTravelled: 0, + } + const render: RenderComp = { container, hpBar, label: null } + const team: TeamComp = { team: 'enemy' } + const loot: LootComp = { gold: 50, essence: 3 } + const statusEffects: StatusEffectsComp = { effects: [] } + + Object.assign(entity, { transform, health, movement, render, team, loot, statusEffects }) + entity.tags.add('enemy') + entity.tags.add('dragon') + entity.tags.add('flying') + entity.tags.add('unit') +} + +export function updateDragonHpBar(entity: Entity): void { + const hp = entity.health as HealthComp + const r = entity.render as RenderComp + if (!hp || !r?.hpBar || (r.hpBar as Graphics).destroyed) return + const bar = r.hpBar as Graphics + const pct = Math.max(0, hp.current / hp.max) + bar.clear() + bar.rect(-HP_W / 2, HP_Y, HP_W * pct, HP_H) + bar.fill({ color: pct > 0.5 ? 0xff2200 : 0xff8800 }) +} diff --git a/src/game/entities/enemies/goblin.ts b/src/game/entities/enemies/goblin.ts new file mode 100644 index 0000000..6b162ce --- /dev/null +++ b/src/game/entities/enemies/goblin.ts @@ -0,0 +1,100 @@ +import { Container, Graphics } from 'pixi.js' +import type { Entity } from '@/game/core/EntityManager' +import type { + TransformComp, HealthComp, MovementComp, TeamComp, LootComp, RenderComp, +} from '@/game/components' +import type { StatusEffectsComp } from '@/game/components/StatusEffect' +import { getWorldContainer } from '@/game/rendering/WorldContext' + +const R = 7 // body half-size reference +const HP_W = 32 +const HP_H = 4 + +export function createGoblin( + entity: Entity, + path: { x: number; y: number }[], + startX: number, + startY: number, +): void { + const container = new Container() + + const body = new Graphics() + + // Ground shadow (slightly below feet) + body.ellipse(0, 4, 10, 4) + body.fill({ color: 0x000000, alpha: 0.35 }) + + // Legs + body.rect(-5, -4, 4, 10) + body.fill({ color: 0x336622 }) + body.rect(2, -4, 4, 10) + body.fill({ color: 0x336622 }) + + // Torso + body.ellipse(0, -12, 7, 8) + body.fill({ color: 0x44aa44 }) + body.ellipse(0, -12, 7, 8) + body.stroke({ color: 0x228822, width: 1 }) + + // Head + body.circle(0, -22, 7) + body.fill({ color: 0x55cc55 }) + body.circle(0, -22, 7) + body.stroke({ color: 0x228822, width: 1 }) + + // Ears (small triangles on sides of head) + body.poly([-7, -25, -12, -22, -7, -19]) + body.fill({ color: 0x44aa44 }) + body.poly([7, -25, 12, -22, 7, -19]) + body.fill({ color: 0x44aa44 }) + + // Eyes + body.circle(-3, -23, 2) + body.fill({ color: 0x111111 }) + body.circle(3, -23, 2) + body.fill({ color: 0x111111 }) + // Eye glint + body.circle(-2, -24, 0.8) + body.fill({ color: 0xff2222 }) + body.circle(4, -24, 0.8) + body.fill({ color: 0xff2222 }) + + const hpBg = new Graphics() + hpBg.rect(-HP_W / 2, -33, HP_W, HP_H) + hpBg.fill({ color: 0x330000 }) + + const hpBar = new Graphics() + hpBar.rect(-HP_W / 2, -33, HP_W, HP_H) + hpBar.fill({ color: 0x44ff44 }) + + container.addChild(body, hpBg, hpBar) + container.x = startX + container.y = startY + container.zIndex = 5 + getWorldContainer().addChild(container) + + const transform: TransformComp = { x: startX, y: startY, rotation: 0 } + const health: HealthComp = { current: 40, max: 40, armor: 0, magicResist: 0 } + const movement: MovementComp = { speed: 80, path, pathIndex: 0, distanceTravelled: 0 } + const team: TeamComp = { team: 'enemy' } + const loot: LootComp = { gold: 5, essence: 0 } + const render: RenderComp = { container, hpBar, label: null } + const statusEffects: StatusEffectsComp = { effects: [] } + + Object.assign(entity, { transform, health, movement, team, loot, render, statusEffects }) + entity.tags.add('enemy') + entity.tags.add('unit') + entity.tags.add('goblin') +} + +export function updateGoblinHpBar(entity: Entity): void { + const h = entity.health as HealthComp + const r = entity.render as RenderComp + if (!r.hpBar || !h) return + + const pct = Math.max(0, h.current / h.max) + r.hpBar.clear() + const color = pct > 0.5 ? 0x44ff44 : pct > 0.25 ? 0xffdd44 : 0xff4444 + r.hpBar.rect(-HP_W / 2, -33, HP_W * pct, HP_H) + r.hpBar.fill({ color }) +} diff --git a/src/game/entities/enemies/golem.ts b/src/game/entities/enemies/golem.ts new file mode 100644 index 0000000..36439c0 --- /dev/null +++ b/src/game/entities/enemies/golem.ts @@ -0,0 +1,121 @@ +import { Container, Graphics } from 'pixi.js' +import type { Entity } from '@/game/core/EntityManager' +import type { TransformComp, HealthComp, MovementComp, TeamComp, LootComp, RenderComp } from '@/game/components' +import type { StatusEffectsComp } from '@/game/components/StatusEffect' +import { getWorldContainer } from '@/game/rendering/WorldContext' + +const HP_W = 46 +const HP_H = 6 +const HP_Y = -50 + +export function createGolem(entity: Entity, path: { x: number; y: number }[], sx: number, sy: number): void { + const container = new Container() + + const body = new Graphics() + + // Large ground shadow + body.ellipse(0, 5, 22, 8) + body.fill({ color: 0x000000, alpha: 0.45 }) + + // Stone legs — massive blocks + body.rect(-14, -8, 11, 18) + body.fill({ color: 0x777777 }) + body.rect(-14, -8, 11, 18) + body.stroke({ color: 0x555555, width: 1.5 }) + body.rect(4, -8, 11, 18) + body.fill({ color: 0x777777 }) + body.rect(4, -8, 11, 18) + body.stroke({ color: 0x555555, width: 1.5 }) + + // Main stone body block + body.rect(-16, -30, 32, 26) + body.fill({ color: 0x888888 }) + body.rect(-16, -30, 32, 26) + body.stroke({ color: 0x555555, width: 3 }) + + // Stone body texture cracks + body.moveTo(-16, -18); body.lineTo(-8, -20); body.lineTo(-6, -10) + body.stroke({ color: 0x666666, width: 1, alpha: 0.7 }) + body.moveTo(16, -22); body.lineTo(8, -20); body.lineTo(10, -12) + body.stroke({ color: 0x666666, width: 1, alpha: 0.7 }) + body.moveTo(-10, -30); body.lineTo(-4, -24); body.lineTo(4, -28) + body.stroke({ color: 0x666666, width: 1, alpha: 0.6 }) + + // Stone arms + body.rect(-26, -28, 10, 20) + body.fill({ color: 0x808080 }) + body.rect(-26, -28, 10, 20) + body.stroke({ color: 0x555555, width: 1.5 }) + body.rect(16, -28, 10, 20) + body.fill({ color: 0x808080 }) + body.rect(16, -28, 10, 20) + body.stroke({ color: 0x555555, width: 1.5 }) + + // Rune glow on chest + body.circle(0, -18, 8) + body.fill({ color: 0x3333cc, alpha: 0.7 }) + body.circle(0, -18, 8) + body.stroke({ color: 0x6666ff, width: 2, alpha: 0.8 }) + // Inner rune diamond + body.poly([0, -23, 5, -18, 0, -13, -5, -18]) + body.fill({ color: 0x8888ff, alpha: 0.8 }) + + // Stone head — square-ish + body.rect(-12, -46, 24, 20) + body.fill({ color: 0x888888 }) + body.rect(-12, -46, 24, 20) + body.stroke({ color: 0x555555, width: 2 }) + + // Stone head texture + body.moveTo(-12, -38); body.lineTo(12, -38) + body.stroke({ color: 0x666666, width: 1, alpha: 0.6 }) + + // Glowing eyes + body.rect(-9, -42, 6, 6) + body.fill({ color: 0x2222ff }) + body.rect(-9, -42, 6, 6) + body.stroke({ color: 0x4444ff, width: 1 }) + body.rect(3, -42, 6, 6) + body.fill({ color: 0x2222ff }) + body.rect(3, -42, 6, 6) + body.stroke({ color: 0x4444ff, width: 1 }) + // Eye glow inner + body.rect(-8, -41, 4, 4) + body.fill({ color: 0x8888ff, alpha: 0.9 }) + body.rect(4, -41, 4, 4) + body.fill({ color: 0x8888ff, alpha: 0.9 }) + + const hpBg = new Graphics() + hpBg.rect(-HP_W / 2, HP_Y, HP_W, HP_H) + hpBg.fill({ color: 0x222244 }) + const hpBar = new Graphics() + hpBar.rect(-HP_W / 2, HP_Y, HP_W, HP_H) + hpBar.fill({ color: 0x4488ff }) + + container.addChild(body, hpBg, hpBar) + container.x = sx + container.y = sy + container.zIndex = 10 + getWorldContainer().addChild(container) + + const transform: TransformComp = { x: sx, y: sy, rotation: 0 } + const health: HealthComp = { current: 400, max: 400, armor: 18, magicResist: 8 } + const movement: MovementComp = { speed: 22, path, pathIndex: 0, distanceTravelled: 0 } + const team: TeamComp = { team: 'enemy' } + const loot: LootComp = { gold: 30, essence: 1 } + const render: RenderComp = { container, hpBar, label: null } + const statusEffects: StatusEffectsComp = { effects: [] } + + Object.assign(entity, { transform, health, movement, team, loot, render, statusEffects }) + entity.tags.add('enemy'); entity.tags.add('unit'); entity.tags.add('golem') +} + +export function updateGolemHpBar(entity: Entity): void { + const h = entity.health as HealthComp + const r = entity.render as RenderComp + if (!r.hpBar || !h) return + const pct = Math.max(0, h.current / h.max) + r.hpBar.clear() + r.hpBar.rect(-HP_W / 2, HP_Y, HP_W * pct, HP_H) + r.hpBar.fill({ color: pct > 0.5 ? 0x4488ff : pct > 0.25 ? 0xffdd44 : 0xff4444 }) +} diff --git a/src/game/entities/enemies/lich_king.ts b/src/game/entities/enemies/lich_king.ts new file mode 100644 index 0000000..6a45ed3 --- /dev/null +++ b/src/game/entities/enemies/lich_king.ts @@ -0,0 +1,203 @@ +import { Container, Graphics } from 'pixi.js' +import type { Entity } from '@/game/core/EntityManager' +import type { TransformComp, HealthComp, MovementComp, TeamComp, LootComp, RenderComp } from '@/game/components' +import type { StatusEffectsComp } from '@/game/components/StatusEffect' +import { getWorldContainer } from '@/game/rendering/WorldContext' + +const HP_W = 80 +const HP_H = 8 +const HP_Y = -90 + +export interface LichKingComp { + phase: 1 | 2 | 3 + summonTimer: number + summonCooldown: number + pulseTimer: number + pulseCooldown: number +} + +export function createLichKing(entity: Entity, path: { x: number; y: number }[], sx: number, sy: number): void { + const container = new Container() + + // Massive dark aura + const aura = new Graphics() + aura.circle(0, -30, 52) + aura.fill({ color: 0x0a0014, alpha: 0.6 }) + aura.circle(0, -30, 52) + aura.stroke({ color: 0x6600cc, width: 3, alpha: 0.7 }) + + // Ground shadow + const shadow = new Graphics() + shadow.ellipse(0, 4, 22, 8) + shadow.fill({ color: 0x000000, alpha: 0.5 }) + + // Tattered robe — wide, imposing + const robe = new Graphics() + robe.poly([0, 2, -22, -10, -20, -48, 0, -54, 20, -48, 22, -10]) + robe.fill({ color: 0x080018 }) + robe.poly([0, 2, -22, -10, -20, -48, 0, -54, 20, -48, 22, -10]) + robe.stroke({ color: 0x4400aa, width: 2 }) + // Robe hem tear details + robe.moveTo(-22, -10); robe.lineTo(-18, -2); robe.lineTo(-14, -8) + robe.stroke({ color: 0x330088, width: 1, alpha: 0.8 }) + robe.moveTo(22, -10); robe.lineTo(18, -2); robe.lineTo(14, -8) + robe.stroke({ color: 0x330088, width: 1, alpha: 0.8 }) + // Belt sigil + robe.circle(0, -28, 7) + robe.fill({ color: 0x1a0033 }) + robe.circle(0, -28, 7) + robe.stroke({ color: 0xcc00ff, width: 2, alpha: 0.9 }) + robe.poly([0, -23, -5, -28, 0, -33, 5, -28]) + robe.fill({ color: 0xcc00ff, alpha: 0.85 }) + + // Pauldrons (shoulder armor) + const pauldrons = new Graphics() + pauldrons.ellipse(-18, -52, 10, 7) + pauldrons.fill({ color: 0x1a1a2e }) + pauldrons.stroke({ color: 0x8800cc, width: 1.5 }) + pauldrons.ellipse(18, -52, 10, 7) + pauldrons.fill({ color: 0x1a1a2e }) + pauldrons.stroke({ color: 0x8800cc, width: 1.5 }) + // Pauldron spikes + pauldrons.poly([-22, -54, -18, -62, -14, -54]) + pauldrons.fill({ color: 0x2a0055 }) + pauldrons.poly([14, -54, 18, -62, 22, -54]) + pauldrons.fill({ color: 0x2a0055 }) + + // Ribcage visible through robe + const ribs = new Graphics() + for (let i = 0; i < 4; i++) { + const ry = -38 + i * 6 + ribs.moveTo(-8, ry); ribs.lineTo(-12, ry + 2); ribs.lineTo(-8, ry + 4) + ribs.stroke({ color: 0xd4c8a0, width: 1, alpha: 0.5 }) + ribs.moveTo(8, ry); ribs.lineTo(12, ry + 2); ribs.lineTo(8, ry + 4) + ribs.stroke({ color: 0xd4c8a0, width: 1, alpha: 0.5 }) + } + + // Skull head + const head = new Graphics() + head.circle(0, -68, 14) + head.fill({ color: 0xe8dfc0 }) + head.stroke({ color: 0xb0a070, width: 1.5 }) + // Cheekbones deep + head.ellipse(-8, -64, 3.5, 4.5) + head.fill({ color: 0xc8b880 }) + head.ellipse(8, -64, 3.5, 4.5) + head.fill({ color: 0xc8b880 }) + // Deep eye sockets + head.ellipse(-5, -71, 5, 6) + head.fill({ color: 0x0a0014 }) + head.circle(-5, -71, 3.5) + head.fill({ color: 0x6600ff }) + head.circle(-5, -71, 2) + head.fill({ color: 0xcc44ff, alpha: 0.95 }) + head.circle(-5, -71, 0.8) + head.fill({ color: 0xffffff, alpha: 0.9 }) + head.ellipse(5, -71, 5, 6) + head.fill({ color: 0x0a0014 }) + head.circle(5, -71, 3.5) + head.fill({ color: 0x6600ff }) + head.circle(5, -71, 2) + head.fill({ color: 0xcc44ff, alpha: 0.95 }) + head.circle(5, -71, 0.8) + head.fill({ color: 0xffffff, alpha: 0.9 }) + // Nasal cavity + head.poly([-2, -64, 0, -60, 2, -64]) + head.fill({ color: 0x1a0030 }) + // Jaw with teeth + head.poly([-10, -60, -8, -57, 8, -57, 10, -60]) + head.fill({ color: 0xe0d5b0 }) + for (let i = -3; i <= 3; i++) { + head.rect(i * 2.5 - 1, -59, 2, 3) + head.fill({ color: 0xc8be90 }) + } + + // Crown of bone + const crown = new Graphics() + crown.poly([-14, -80, -10, -80, -8, -92, -6, -80, -2, -80, 0, -94, 2, -80, 6, -80, 8, -92, 10, -80, 14, -80, 14, -76, -14, -76]) + crown.fill({ color: 0x1a1a2e }) + crown.stroke({ color: 0x8800cc, width: 1.5 }) + // Crown gems + crown.circle(-8, -88, 3) + crown.fill({ color: 0xcc00ff, alpha: 0.9 }) + crown.circle(0, -90, 3.5) + crown.fill({ color: 0xff00ff, alpha: 0.9 }) + crown.circle(8, -88, 3) + crown.fill({ color: 0xcc00ff, alpha: 0.9 }) + + // Staff of Undeath + const staff = new Graphics() + staff.rect(16, -80, 4, 84) + staff.fill({ color: 0x1a0a0a }) + staff.stroke({ color: 0x440022, width: 1 }) + // Skull on staff + staff.circle(18, -84, 9) + staff.fill({ color: 0xe0d5b0 }) + staff.ellipse(15, -87, 3, 4) + staff.fill({ color: 0x0a0014 }) + staff.ellipse(21, -87, 3, 4) + staff.fill({ color: 0x0a0014 }) + // Staff orb glow + staff.circle(18, -84, 14) + staff.stroke({ color: 0xcc00ff, width: 2.5, alpha: 0.6 }) + staff.circle(18, -84, 7) + staff.fill({ color: 0x6600cc, alpha: 0.75 }) + + // HP bar + const hpBg = new Graphics() + hpBg.rect(-HP_W / 2, HP_Y, HP_W, HP_H) + hpBg.fill({ color: 0x110022 }) + hpBg.rect(-HP_W / 2, HP_Y, HP_W, HP_H) + hpBg.stroke({ color: 0x6600cc, width: 1 }) + const hpBar = new Graphics() + hpBar.rect(-HP_W / 2, HP_Y, HP_W, HP_H) + hpBar.fill({ color: 0x8800ff }) + + // Phase indicator pips (3 small diamonds above HP bar) + const phasePips = new Graphics() + for (let i = 0; i < 3; i++) { + const px = -16 + i * 16 + phasePips.poly([px, HP_Y - 10, px - 5, HP_Y - 6, px, HP_Y - 2, px + 5, HP_Y - 6]) + phasePips.fill({ color: 0x8800ff, alpha: 0.9 }) + } + + container.addChild(aura, shadow, robe, pauldrons, ribs, head, crown, staff, hpBg, hpBar, phasePips) + container.x = sx + container.y = sy + container.zIndex = 60 + getWorldContainer().addChild(container) + + const transform: TransformComp = { x: sx, y: sy, rotation: 0 } + const health: HealthComp = { current: 1500, max: 1500, armor: 5, magicResist: 8 } + const movement: MovementComp = { speed: 28, path: [...path], pathIndex: 0, distanceTravelled: 0 } + const render: RenderComp = { container, hpBar, label: null } + const team: TeamComp = { team: 'enemy' } + const loot: LootComp = { gold: 150, essence: 10 } + const statusEffects: StatusEffectsComp = { effects: [] } + const lichKingComp: LichKingComp = { + phase: 1, + summonTimer: 7, + summonCooldown: 7, + pulseTimer: 0, + pulseCooldown: 4, + } + + Object.assign(entity, { transform, health, movement, render, team, loot, statusEffects, lichKingComp }) + entity.tags.add('enemy') + entity.tags.add('lich_king') + entity.tags.add('boss') + entity.tags.add('unit') +} + +export function updateLichKingHpBar(entity: Entity): void { + const hp = entity.health as HealthComp + const r = entity.render as RenderComp + if (!hp || !r?.hpBar || (r.hpBar as Graphics).destroyed) return + const bar = r.hpBar as Graphics + const pct = Math.max(0, hp.current / hp.max) + bar.clear() + bar.rect(-HP_W / 2, HP_Y, HP_W * pct, HP_H) + const lk = entity.lichKingComp as LichKingComp | undefined + const color = lk?.phase === 3 ? 0xff0044 : lk?.phase === 2 ? 0xdd00ff : 0x8800ff + bar.fill({ color }) +} diff --git a/src/game/entities/enemies/necromancer.ts b/src/game/entities/enemies/necromancer.ts new file mode 100644 index 0000000..b7b03f1 --- /dev/null +++ b/src/game/entities/enemies/necromancer.ts @@ -0,0 +1,129 @@ +import { Container, Graphics } from 'pixi.js' +import type { Entity } from '@/game/core/EntityManager' +import type { TransformComp, HealthComp, MovementComp, TeamComp, LootComp, RenderComp } from '@/game/components' +import type { StatusEffectsComp } from '@/game/components/StatusEffect' +import { getWorldContainer } from '@/game/rendering/WorldContext' + +const HP_W = 44 +const HP_H = 6 +const HP_Y = -50 + +export interface NecroComp { + healTimer: number + healCooldown: number + healAmount: number + healRadius: number +} + +export function createNecromancer(entity: Entity, path: { x: number; y: number }[], sx: number, sy: number): void { + const container = new Container() + + // Dark aura + const aura = new Graphics() + aura.circle(0, -16, 26) + aura.fill({ color: 0x220033, alpha: 0.45 }) + aura.circle(0, -16, 26) + aura.stroke({ color: 0x660099, width: 2, alpha: 0.55 }) + + // Ground shadow + const shadow = new Graphics() + shadow.ellipse(0, 4, 14, 5) + shadow.fill({ color: 0x000000, alpha: 0.35 }) + + // Robe body — wide at base, iso 2.5D style + const robe = new Graphics() + // Robe base / hem + robe.poly([0, 0, -14, -8, -12, -28, 0, -32, 12, -28, 14, -8]) + robe.fill({ color: 0x1a0033 }) + robe.poly([0, 0, -14, -8, -12, -28, 0, -32, 12, -28, 14, -8]) + robe.stroke({ color: 0x660099, width: 1.5 }) + // Robe hem detail line + robe.moveTo(-13, -10); robe.lineTo(13, -10) + robe.stroke({ color: 0x9900cc, width: 1, alpha: 0.7 }) + // Belt rune diamond + robe.poly([0, -16, -5, -20, 0, -24, 5, -20]) + robe.fill({ color: 0x9900cc, alpha: 0.9 }) + + // Head/skull + const head = new Graphics() + head.circle(0, -38, 8) + head.fill({ color: 0xddd8c4 }) + // Cheekbones + head.ellipse(-5, -36, 2.5, 3) + head.fill({ color: 0xccca9a }) + head.ellipse(5, -36, 2.5, 3) + head.fill({ color: 0xccca9a }) + // Eye sockets glowing + head.ellipse(-2.5, -40, 2.5, 3) + head.fill({ color: 0x550088 }) + head.circle(-2.5, -40, 2) + head.fill({ color: 0xaa00ff, alpha: 0.9 }) + head.ellipse(2.5, -40, 2.5, 3) + head.fill({ color: 0x550088 }) + head.circle(2.5, -40, 2) + head.fill({ color: 0xaa00ff, alpha: 0.9 }) + + // Pointed hood + const hood = new Graphics() + hood.poly([0, -58, -10, -44, 10, -44]) + hood.fill({ color: 0x1a0033 }) + hood.stroke({ color: 0x9900cc, width: 1.5 }) + hood.rect(-11, -44, 22, 7) + hood.fill({ color: 0x2a0055 }) + hood.stroke({ color: 0x660099, width: 1 }) + + // Necrotic staff + const staff = new Graphics() + staff.rect(11, -48, 3, 52) + staff.fill({ color: 0x3a2a1a }) + // Skull ornament on staff top + staff.circle(12, -50, 5) + staff.fill({ color: 0xddd8c4 }) + staff.ellipse(10, -52, 2, 2.5) + staff.fill({ color: 0x330044 }) + staff.ellipse(14, -52, 2, 2.5) + staff.fill({ color: 0x330044 }) + // Staff glow + staff.circle(12, -50, 8) + staff.stroke({ color: 0x9900cc, width: 2, alpha: 0.55 }) + staff.circle(12, -50, 4) + staff.fill({ color: 0x9900cc, alpha: 0.7 }) + + const hpBg = new Graphics() + hpBg.rect(-HP_W / 2, HP_Y, HP_W, HP_H) + hpBg.fill({ color: 0x220022 }) + const hpBar = new Graphics() + hpBar.rect(-HP_W / 2, HP_Y, HP_W, HP_H) + hpBar.fill({ color: 0xaa00cc }) + + container.addChild(aura, shadow, robe, head, hood, staff, hpBg, hpBar) + container.x = sx + container.y = sy + container.zIndex = 10 + getWorldContainer().addChild(container) + + const transform: TransformComp = { x: sx, y: sy, rotation: 0 } + const health: HealthComp = { current: 350, max: 350, armor: 8, magicResist: 5 } + const movement: MovementComp = { speed: 40, path: [...path], pathIndex: 0, distanceTravelled: 0 } + const render: RenderComp = { container, hpBar, label: null } + const team: TeamComp = { team: 'enemy' } + const loot: LootComp = { gold: 40, essence: 2 } + const statusEffects: StatusEffectsComp = { effects: [] } + const necroComp: NecroComp = { healTimer: 3, healCooldown: 5, healAmount: 40, healRadius: 100 } + + Object.assign(entity, { transform, health, movement, render, team, loot, statusEffects, necroComp }) + entity.tags.add('enemy') + entity.tags.add('necromancer') + entity.tags.add('unit') +} + +export function updateNecromancerHpBar(entity: Entity): void { + const hp = entity.health as HealthComp + const r = entity.render as RenderComp + if (!hp || !r?.hpBar || (r.hpBar as Graphics).destroyed) return + const bar = r.hpBar as Graphics + const pct = Math.max(0, hp.current / hp.max) + bar.clear() + bar.rect(-HP_W / 2, HP_Y, HP_W * pct, HP_H) + bar.fill({ color: pct > 0.5 ? 0xaa00cc : 0xff0066 }) +} diff --git a/src/game/entities/enemies/orc.ts b/src/game/entities/enemies/orc.ts new file mode 100644 index 0000000..79d575a --- /dev/null +++ b/src/game/entities/enemies/orc.ts @@ -0,0 +1,104 @@ +import { Container, Graphics } from 'pixi.js' +import type { Entity } from '@/game/core/EntityManager' +import type { TransformComp, HealthComp, MovementComp, TeamComp, LootComp, RenderComp } from '@/game/components' +import type { StatusEffectsComp } from '@/game/components/StatusEffect' +import { getWorldContainer } from '@/game/rendering/WorldContext' + +const HP_W = 32 +const HP_H = 5 +const HP_Y = -40 + +export function createOrc(entity: Entity, path: { x: number; y: number }[], sx: number, sy: number): void { + const container = new Container() + + const body = new Graphics() + + // Ground shadow + body.ellipse(0, 4, 14, 5) + body.fill({ color: 0x000000, alpha: 0.35 }) + + // Legs — wider, dark green + body.rect(-9, -6, 7, 14) + body.fill({ color: 0x2a5a1a }) + body.rect(2, -6, 7, 14) + body.fill({ color: 0x2a5a1a }) + + // Armor/torso block + body.rect(-10, -22, 20, 18) + body.fill({ color: 0x3a6a28 }) + body.rect(-10, -22, 20, 18) + body.stroke({ color: 0x888888, width: 1.5 }) + // Armor chest lines + body.moveTo(-10, -14) + body.lineTo(10, -14) + body.stroke({ color: 0x666666, width: 1, alpha: 0.7 }) + body.moveTo(0, -22) + body.lineTo(0, -4) + body.stroke({ color: 0x666666, width: 1, alpha: 0.5 }) + + // Shoulders — pauldrons + body.ellipse(-13, -18, 6, 5) + body.fill({ color: 0x777777 }) + body.ellipse(-13, -18, 6, 5) + body.stroke({ color: 0x444444, width: 1 }) + body.ellipse(13, -18, 6, 5) + body.fill({ color: 0x777777 }) + body.ellipse(13, -18, 6, 5) + body.stroke({ color: 0x444444, width: 1 }) + + // Head + body.circle(0, -32, 10) + body.fill({ color: 0x4a7a2a }) + body.circle(0, -32, 10) + body.stroke({ color: 0x2a5a1a, width: 1.5 }) + + // Tusks (white protrusions from lower head) + body.poly([-5, -25, -7, -20, -3, -22]) + body.fill({ color: 0xeeeebb }) + body.poly([5, -25, 7, -20, 3, -22]) + body.fill({ color: 0xeeeebb }) + + // Eyes + body.circle(-4, -33, 3) + body.fill({ color: 0xff4400 }) + body.circle(-4, -33, 1.5) + body.fill({ color: 0xffaa00 }) + body.circle(4, -33, 3) + body.fill({ color: 0xff4400 }) + body.circle(4, -33, 1.5) + body.fill({ color: 0xffaa00 }) + + const hpBg = new Graphics() + hpBg.rect(-HP_W / 2, HP_Y, HP_W, HP_H) + hpBg.fill({ color: 0x330000 }) + const hpBar = new Graphics() + hpBar.rect(-HP_W / 2, HP_Y, HP_W, HP_H) + hpBar.fill({ color: 0x44ff44 }) + + container.addChild(body, hpBg, hpBar) + container.x = sx + container.y = sy + container.zIndex = 5 + getWorldContainer().addChild(container) + + const transform: TransformComp = { x: sx, y: sy, rotation: 0 } + const health: HealthComp = { current: 120, max: 120, armor: 6, magicResist: 0 } + const movement: MovementComp = { speed: 45, path, pathIndex: 0, distanceTravelled: 0 } + const team: TeamComp = { team: 'enemy' } + const loot: LootComp = { gold: 12, essence: 0 } + const render: RenderComp = { container, hpBar, label: null } + const statusEffects: StatusEffectsComp = { effects: [] } + + Object.assign(entity, { transform, health, movement, team, loot, render, statusEffects }) + entity.tags.add('enemy'); entity.tags.add('unit'); entity.tags.add('orc') +} + +export function updateOrcHpBar(entity: Entity): void { + const h = entity.health as HealthComp + const r = entity.render as RenderComp + if (!r.hpBar || !h) return + const pct = Math.max(0, h.current / h.max) + r.hpBar.clear() + r.hpBar.rect(-HP_W / 2, HP_Y, HP_W * pct, HP_H) + r.hpBar.fill({ color: pct > 0.5 ? 0x44ff44 : pct > 0.25 ? 0xffdd44 : 0xff4444 }) +} diff --git a/src/game/entities/enemies/troll.ts b/src/game/entities/enemies/troll.ts new file mode 100644 index 0000000..905b296 --- /dev/null +++ b/src/game/entities/enemies/troll.ts @@ -0,0 +1,113 @@ +import { Container, Graphics } from 'pixi.js' +import type { Entity } from '@/game/core/EntityManager' +import type { TransformComp, HealthComp, MovementComp, TeamComp, LootComp, RenderComp } from '@/game/components' +import type { StatusEffectsComp } from '@/game/components/StatusEffect' +import { getWorldContainer } from '@/game/rendering/WorldContext' + +const HP_W = 44 +const HP_H = 5 +const HP_Y = -50 + +export function createTroll(entity: Entity, path: { x: number; y: number }[], sx: number, sy: number): void { + const container = new Container() + + const body = new Graphics() + + // Large ground shadow + body.ellipse(0, 5, 20, 7) + body.fill({ color: 0x000000, alpha: 0.4 }) + + // Feet/legs + body.rect(-14, -6, 10, 16) + body.fill({ color: 0x3a6a1a }) + body.rect(4, -6, 10, 16) + body.fill({ color: 0x3a6a1a }) + + // Massive torso + body.ellipse(0, -18, 16, 18) + body.fill({ color: 0x4a7a2a }) + body.ellipse(0, -18, 16, 18) + body.stroke({ color: 0x2a5a1a, width: 2 }) + + // Warts/bumps on body + body.circle(-10, -14, 4) + body.fill({ color: 0x3a6a1a }) + body.circle(9, -12, 3) + body.fill({ color: 0x3a6a1a }) + body.circle(-4, -24, 3) + body.fill({ color: 0x3a6a1a }) + body.circle(8, -22, 4) + body.fill({ color: 0x2a5a1a }) + + // Arms — thick + body.ellipse(-18, -20, 6, 12) + body.fill({ color: 0x4a7a2a }) + body.ellipse(18, -20, 6, 12) + body.fill({ color: 0x4a7a2a }) + + // Big round head + body.circle(0, -38, 14) + body.fill({ color: 0x5a8a3a }) + body.circle(0, -38, 14) + body.stroke({ color: 0x2a5a1a, width: 2 }) + + // Brow ridge + body.ellipse(0, -44, 14, 5) + body.fill({ color: 0x3a6a1a }) + + // Warts on head + body.circle(-8, -36, 3) + body.fill({ color: 0x3a6a1a }) + + // Eyes + body.circle(-5, -39, 3.5) + body.fill({ color: 0xff3300 }) + body.circle(-5, -39, 1.5) + body.fill({ color: 0xffaa00 }) + body.circle(5, -39, 3.5) + body.fill({ color: 0xff3300 }) + body.circle(5, -39, 1.5) + body.fill({ color: 0xffaa00 }) + + // Nose + body.ellipse(0, -35, 4, 3) + body.fill({ color: 0x4a7a2a }) + + // Regen glow effect — subtle green aura + body.circle(0, -18, 20) + body.stroke({ color: 0x44ff44, width: 1.5, alpha: 0.2 }) + + const hpBg = new Graphics() + hpBg.rect(-HP_W / 2, HP_Y, HP_W, HP_H) + hpBg.fill({ color: 0x330000 }) + const hpBar = new Graphics() + hpBar.rect(-HP_W / 2, HP_Y, HP_W, HP_H) + hpBar.fill({ color: 0x44ff44 }) + + container.addChild(body, hpBg, hpBar) + container.x = sx + container.y = sy + container.zIndex = 10 + getWorldContainer().addChild(container) + + const transform: TransformComp = { x: sx, y: sy, rotation: 0 } + const health: HealthComp = { current: 220, max: 220, armor: 4, magicResist: 0, regenRate: 8 } + const movement: MovementComp = { speed: 35, path, pathIndex: 0, distanceTravelled: 0 } + const team: TeamComp = { team: 'enemy' } + const loot: LootComp = { gold: 22, essence: 0 } + const render: RenderComp = { container, hpBar, label: null } + const statusEffects: StatusEffectsComp = { effects: [] } + + Object.assign(entity, { transform, health, movement, team, loot, render, statusEffects }) + entity.tags.add('enemy'); entity.tags.add('unit'); entity.tags.add('troll') +} + +export function updateTrollHpBar(entity: Entity): void { + const h = entity.health as HealthComp + const r = entity.render as RenderComp + if (!r.hpBar || !h) return + const pct = Math.max(0, h.current / h.max) + r.hpBar.clear() + r.hpBar.rect(-HP_W / 2, HP_Y, HP_W * pct, HP_H) + r.hpBar.fill({ color: pct > 0.5 ? 0x44ff44 : pct > 0.25 ? 0xffdd44 : 0xff4444 }) +} diff --git a/src/game/entities/enemies/updateHpBar.ts b/src/game/entities/enemies/updateHpBar.ts new file mode 100644 index 0000000..297b355 --- /dev/null +++ b/src/game/entities/enemies/updateHpBar.ts @@ -0,0 +1,20 @@ +import type { Entity } from '@/game/core/EntityManager' +import { updateGoblinHpBar } from './goblin' +import { updateOrcHpBar } from './orc' +import { updateWargHpBar } from './warg' +import { updateWraithHpBar } from './wraith' +import { updateTrollHpBar } from './troll' +import { updateGolemHpBar } from './golem' +import { updateNecromancerHpBar } from './necromancer' +import { updateDragonHpBar } from './dragon' + +export function refreshHp(entity: Entity): void { + if (entity.tags.has('goblin')) updateGoblinHpBar(entity) + else if (entity.tags.has('orc')) updateOrcHpBar(entity) + else if (entity.tags.has('warg')) updateWargHpBar(entity) + else if (entity.tags.has('wraith')) updateWraithHpBar(entity) + else if (entity.tags.has('troll')) updateTrollHpBar(entity) + else if (entity.tags.has('golem')) updateGolemHpBar(entity) + else if (entity.tags.has('necromancer')) updateNecromancerHpBar(entity) + else if (entity.tags.has('dragon')) updateDragonHpBar(entity) +} diff --git a/src/game/entities/enemies/warg.ts b/src/game/entities/enemies/warg.ts new file mode 100644 index 0000000..74e3569 --- /dev/null +++ b/src/game/entities/enemies/warg.ts @@ -0,0 +1,101 @@ +import { Container, Graphics } from 'pixi.js' +import type { Entity } from '@/game/core/EntityManager' +import type { TransformComp, HealthComp, MovementComp, TeamComp, LootComp, RenderComp } from '@/game/components' +import type { StatusEffectsComp } from '@/game/components/StatusEffect' +import { getWorldContainer } from '@/game/rendering/WorldContext' + +const HP_W = 28 +const HP_H = 4 +const HP_Y = -26 + +export function createWarg(entity: Entity, path: { x: number; y: number }[], sx: number, sy: number): void { + const container = new Container() + + const body = new Graphics() + + // Ground shadow + body.ellipse(0, 4, 14, 5) + body.fill({ color: 0x000000, alpha: 0.35 }) + + // 4 legs — small rects + body.rect(-10, -4, 4, 10) + body.fill({ color: 0x5a3a1a }) + body.rect(-4, -3, 4, 9) + body.fill({ color: 0x5a3a1a }) + body.rect(2, -3, 4, 9) + body.fill({ color: 0x5a3a1a }) + body.rect(8, -4, 4, 10) + body.fill({ color: 0x5a3a1a }) + + // Body (low, wide ellipse) + body.ellipse(0, -10, 14, 8) + body.fill({ color: 0x6a4a2a }) + body.ellipse(0, -10, 14, 8) + body.stroke({ color: 0x4a2a0a, width: 1 }) + + // Tail + body.poly([-14, -10, -20, -14, -16, -7]) + body.fill({ color: 0x5a3a1a }) + + // Head — forward shifted in iso +x direction + body.ellipse(10, -14, 8, 6) + body.fill({ color: 0x7a5a3a }) + body.ellipse(10, -14, 8, 6) + body.stroke({ color: 0x4a2a0a, width: 1 }) + + // Snout + body.ellipse(15, -13, 4, 3) + body.fill({ color: 0x6a4a2a }) + + // Ears — triangles on head + body.poly([8, -18, 5, -24, 11, -20]) + body.fill({ color: 0x5a3a1a }) + body.poly([14, -18, 12, -24, 17, -20]) + body.fill({ color: 0x5a3a1a }) + + // Eye + body.circle(12, -15, 2) + body.fill({ color: 0xff8800 }) + body.circle(12, -15, 1) + body.fill({ color: 0xffcc00 }) + + // Fur texture marks + body.moveTo(-8, -12); body.lineTo(-2, -9) + body.stroke({ color: 0x4a2a0a, width: 0.8, alpha: 0.5 }) + body.moveTo(2, -14); body.lineTo(8, -12) + body.stroke({ color: 0x4a2a0a, width: 0.8, alpha: 0.5 }) + + const hpBg = new Graphics() + hpBg.rect(-HP_W / 2, HP_Y, HP_W, HP_H) + hpBg.fill({ color: 0x330000 }) + const hpBar = new Graphics() + hpBar.rect(-HP_W / 2, HP_Y, HP_W, HP_H) + hpBar.fill({ color: 0x44ff44 }) + + container.addChild(body, hpBg, hpBar) + container.x = sx + container.y = sy + container.zIndex = 5 + getWorldContainer().addChild(container) + + const transform: TransformComp = { x: sx, y: sy, rotation: 0 } + const health: HealthComp = { current: 25, max: 25, armor: 0, magicResist: 0 } + const movement: MovementComp = { speed: 140, path, pathIndex: 0, distanceTravelled: 0 } + const team: TeamComp = { team: 'enemy' } + const loot: LootComp = { gold: 8, essence: 0 } + const render: RenderComp = { container, hpBar, label: null } + const statusEffects: StatusEffectsComp = { effects: [] } + + Object.assign(entity, { transform, health, movement, team, loot, render, statusEffects }) + entity.tags.add('enemy'); entity.tags.add('unit'); entity.tags.add('warg') +} + +export function updateWargHpBar(entity: Entity): void { + const h = entity.health as HealthComp + const r = entity.render as RenderComp + if (!r.hpBar || !h) return + const pct = Math.max(0, h.current / h.max) + r.hpBar.clear() + r.hpBar.rect(-HP_W / 2, HP_Y, HP_W * pct, HP_H) + r.hpBar.fill({ color: pct > 0.5 ? 0x44ff44 : pct > 0.25 ? 0xffdd44 : 0xff4444 }) +} diff --git a/src/game/entities/enemies/wraith.ts b/src/game/entities/enemies/wraith.ts new file mode 100644 index 0000000..aacaf02 --- /dev/null +++ b/src/game/entities/enemies/wraith.ts @@ -0,0 +1,88 @@ +import { Container, Graphics } from 'pixi.js' +import type { Entity } from '@/game/core/EntityManager' +import type { TransformComp, HealthComp, MovementComp, TeamComp, LootComp, RenderComp } from '@/game/components' +import type { StatusEffectsComp } from '@/game/components/StatusEffect' +import { getWorldContainer } from '@/game/rendering/WorldContext' + +const HP_W = 30 +const HP_H = 4 +const HP_Y = -45 + +export function createWraith(entity: Entity, path: { x: number; y: number }[], sx: number, sy: number): void { + const container = new Container() + + const body = new Graphics() + + // No shadow — floats! + + // Flowing robe — wide at bottom, tapering up, semi-transparent + body.poly([0, -38, -14, -20, -18, -5, -14, 4, 0, 6, 14, 4, 18, -5, 14, -20]) + body.fill({ color: 0x6633aa, alpha: 0.75 }) + body.poly([0, -38, -14, -20, -18, -5, -14, 4, 0, 6, 14, 4, 18, -5, 14, -20]) + body.stroke({ color: 0xcc99ff, width: 1.5, alpha: 0.85 }) + + // Robe tattered bottom — wispy tendrils + body.poly([-14, 4, -18, 12, -10, 8, -6, 14, 0, 6]) + body.fill({ color: 0x8844cc, alpha: 0.55 }) + body.poly([0, 6, 6, 14, 10, 8, 18, 12, 14, 4]) + body.fill({ color: 0x8844cc, alpha: 0.55 }) + + // Inner robe highlight + body.poly([0, -36, -8, -20, -10, -5, 0, 0, 10, -5, 8, -20]) + body.fill({ color: 0x9966cc, alpha: 0.4 }) + + // Hood/head top + body.ellipse(0, -32, 9, 8) + body.fill({ color: 0x441188, alpha: 0.9 }) + + // Glowing eyes + body.circle(-4, -30, 3) + body.fill({ color: 0xffaaff, alpha: 0.95 }) + body.circle(-4, -30, 1.5) + body.fill({ color: 0xffffff, alpha: 0.9 }) + body.circle(4, -30, 3) + body.fill({ color: 0xffaaff, alpha: 0.95 }) + body.circle(4, -30, 1.5) + body.fill({ color: 0xffffff, alpha: 0.9 }) + + // Magical aura wisps + body.circle(-6, -15, 4) + body.fill({ color: 0x9944cc, alpha: 0.3 }) + body.circle(6, -18, 3) + body.fill({ color: 0xbb66ff, alpha: 0.3 }) + + const hpBg = new Graphics() + hpBg.rect(-HP_W / 2, HP_Y, HP_W, HP_H) + hpBg.fill({ color: 0x330033 }) + const hpBar = new Graphics() + hpBar.rect(-HP_W / 2, HP_Y, HP_W, HP_H) + hpBar.fill({ color: 0xcc44ff }) + + container.addChild(body, hpBg, hpBar) + container.x = sx + container.y = sy + container.alpha = 0.88 + container.zIndex = 5 + getWorldContainer().addChild(container) + + const transform: TransformComp = { x: sx, y: sy, rotation: 0 } + const health: HealthComp = { current: 55, max: 55, armor: 0, magicResist: 0, physicalImmune: true } + const movement: MovementComp = { speed: 90, path, pathIndex: 0, distanceTravelled: 0 } + const team: TeamComp = { team: 'enemy' } + const loot: LootComp = { gold: 15, essence: 0 } + const render: RenderComp = { container, hpBar, label: null } + const statusEffects: StatusEffectsComp = { effects: [] } + + Object.assign(entity, { transform, health, movement, team, loot, render, statusEffects }) + entity.tags.add('enemy'); entity.tags.add('unit'); entity.tags.add('wraith') +} + +export function updateWraithHpBar(entity: Entity): void { + const h = entity.health as HealthComp + const r = entity.render as RenderComp + if (!r.hpBar || !h) return + const pct = Math.max(0, h.current / h.max) + r.hpBar.clear() + r.hpBar.rect(-HP_W / 2, HP_Y, HP_W * pct, HP_H) + r.hpBar.fill({ color: pct > 0.5 ? 0xcc44ff : pct > 0.25 ? 0xffaaff : 0xff4444 }) +} diff --git a/src/game/entities/hero/archmage.ts b/src/game/entities/hero/archmage.ts new file mode 100644 index 0000000..6a80f73 --- /dev/null +++ b/src/game/entities/hero/archmage.ts @@ -0,0 +1,110 @@ +import { Container, Graphics } from 'pixi.js' +import type { Entity } from '@/game/core/EntityManager' +import type { TransformComp, RenderComp } from '@/game/components' +import type { HeroComp } from '@/game/components/Hero' +import { makeSpells } from '@/game/components/Hero' +import { getWorldContainer } from '@/game/rendering/WorldContext' + +export function createArchmage(entity: Entity, startX: number, startY: number): void { + const container = new Container() + + // Ground shadow + const shadow = new Graphics() + shadow.ellipse(0, 4, 10, 4) + shadow.fill({ color: 0x000000, alpha: 0.35 }) + + // Arcane aura + const aura = new Graphics() + aura.circle(0, -16, 24) + aura.stroke({ color: 0x9966ff, width: 2, alpha: 0.4 }) + aura.circle(0, -16, 20) + aura.stroke({ color: 0xcc99ff, width: 1, alpha: 0.2 }) + + // Robe — tapers from wide hem to waist + const robe = new Graphics() + // Robe hem/base + robe.poly([0, 0, -12, -8, -10, -28, 0, -32, 10, -28, 12, -8]) + robe.fill({ color: 0x2a1a4a }) + robe.poly([0, 0, -12, -8, -10, -28, 0, -32, 10, -28, 12, -8]) + robe.stroke({ color: 0x6633aa, width: 1.5 }) + // Robe hem gold trim + robe.moveTo(-11, -10); robe.lineTo(11, -10) + robe.stroke({ color: 0xffd700, width: 1, alpha: 0.65 }) + // Belt buckle gem + robe.poly([0, -18, -4, -22, 0, -26, 4, -22]) + robe.fill({ color: 0x00ccff, alpha: 0.9 }) + + // Head (slightly tilted forward for iso feel) + const head = new Graphics() + head.circle(0, -38, 7) + head.fill({ color: 0xd4a574 }) + // Beard + head.ellipse(0, -33, 4, 4) + head.fill({ color: 0xd4c0a0 }) + // Eyes + head.circle(-2, -39, 1.2) + head.fill({ color: 0x222222 }) + head.circle(2, -39, 1.2) + head.fill({ color: 0x222222 }) + // Eye highlight + head.circle(-1.5, -39.5, 0.5) + head.fill({ color: 0xffffff }) + head.circle(2.5, -39.5, 0.5) + head.fill({ color: 0xffffff }) + + // Wizard hat + const hat = new Graphics() + // Hat brim + hat.rect(-10, -44, 20, 5) + hat.fill({ color: 0x2a1050 }) + hat.rect(-10, -44, 20, 5) + hat.stroke({ color: 0x9966ff, width: 1 }) + // Hat cone + hat.poly([0, -62, -8, -44, 8, -44]) + hat.fill({ color: 0x1a0a2e }) + hat.stroke({ color: 0x9966ff, width: 1.5 }) + // Hat star / rune + hat.circle(0, -52, 2.5) + hat.fill({ color: 0xffd700 }) + // Hat band stripe + hat.moveTo(-8, -46); hat.lineTo(8, -46) + hat.stroke({ color: 0xffd700, width: 1, alpha: 0.7 }) + + // Staff (right side, rising upward) + const staff = new Graphics() + staff.rect(10, -50, 3, 54) + staff.fill({ color: 0x5a3a1a }) + staff.stroke({ color: 0x8a5a2a, width: 0.8 }) + // Staff orb top + staff.circle(11, -52, 6) + staff.fill({ color: 0x9966ff, alpha: 0.9 }) + staff.circle(11, -52, 4) + staff.fill({ color: 0xcc99ff, alpha: 0.8 }) + staff.circle(11, -52, 2) + staff.fill({ color: 0xffffff, alpha: 0.75 }) + // Staff glow ring + staff.circle(11, -52, 9) + staff.stroke({ color: 0x9966ff, width: 1.5, alpha: 0.4 }) + + container.addChild(shadow, aura, robe, head, hat, staff) + container.x = startX + container.y = startY + container.zIndex = 100 + getWorldContainer().addChild(container) + + const transform: TransformComp = { x: startX, y: startY, rotation: 0 } + const render: RenderComp = { container, hpBar: null, label: null } + const hero: HeroComp = { + targetX: null, + targetY: null, + speed: 120, + xp: 0, + level: 1, + spells: makeSpells(), + bobTime: 0, + } + + Object.assign(entity, { transform, render, hero }) + entity.tags.add('hero') + entity.tags.add('unit') +} diff --git a/src/game/entities/projectiles/arrow.ts b/src/game/entities/projectiles/arrow.ts new file mode 100644 index 0000000..c174b34 --- /dev/null +++ b/src/game/entities/projectiles/arrow.ts @@ -0,0 +1,46 @@ +import { Container, Graphics } from 'pixi.js' +import type { Entity } from '@/game/core/EntityManager' +import type { TransformComp, ProjectileComp, RenderComp } from '@/game/components' +import { getWorldContainer } from '@/game/rendering/WorldContext' + +export function createArrow( + entity: Entity, + startX: number, + startY: number, + targetId: number, + damage: number, + ownerId: number, +): void { + const container = new Container() + + const gfx = new Graphics() + // arrow shaft + gfx.rect(-6, -1, 10, 2) + gfx.fill({ color: 0xc9a14a }) + // arrowhead + gfx.poly([-6, -3, -6, 3, -12, 0]) + gfx.fill({ color: 0xe8e0c0 }) + // trail glow + gfx.rect(4, -0.5, 8, 1) + gfx.fill({ color: 0xc9a14a, alpha: 0.4 }) + + container.addChild(gfx) + container.x = startX + container.y = startY + getWorldContainer().addChild(container) + + const transform: TransformComp = { x: startX, y: startY, rotation: 0 } + const projectile: ProjectileComp = { + speed: 360, + targetId, + damage, + damageType: 'physical', + ownerId, + hitRadius: 12, + projectileType: 'arrow', + } + const render: RenderComp = { container, hpBar: null, label: null } + + Object.assign(entity, { transform, projectile, render }) + entity.tags.add('projectile') +} diff --git a/src/game/entities/projectiles/fireball.ts b/src/game/entities/projectiles/fireball.ts new file mode 100644 index 0000000..61cdae8 --- /dev/null +++ b/src/game/entities/projectiles/fireball.ts @@ -0,0 +1,39 @@ +import { Container, Graphics } from 'pixi.js' +import type { Entity } from '@/game/core/EntityManager' +import type { TransformComp, ProjectileComp, RenderComp } from '@/game/components' +import { getWorldContainer } from '@/game/rendering/WorldContext' + +export function createFireball( + entity: Entity, + startX: number, + startY: number, + targetId: number, + damage: number, + ownerId: number, +): void { + const container = new Container() + + const gfx = new Graphics() + gfx.circle(0, 0, 7) + gfx.fill({ color: 0xff6600 }) + gfx.circle(0, 0, 4) + gfx.fill({ color: 0xffdd00 }) + + const trail = new Graphics() + trail.circle(4, 0, 4) + trail.fill({ color: 0xff4400, alpha: 0.5 }) + trail.circle(9, 0, 2.5) + trail.fill({ color: 0xff2200, alpha: 0.3 }) + + container.addChild(trail, gfx) + container.x = startX + container.y = startY + getWorldContainer().addChild(container) + + Object.assign(entity, { + transform: { x: startX, y: startY, rotation: 0 } as TransformComp, + projectile: { speed: 280, targetId, damage, damageType: 'magic', ownerId, hitRadius: 14, projectileType: 'fireball' } as ProjectileComp, + render: { container, hpBar: null, label: null } as RenderComp, + }) + entity.tags.add('projectile') +} diff --git a/src/game/entities/projectiles/icicle.ts b/src/game/entities/projectiles/icicle.ts new file mode 100644 index 0000000..0a89c06 --- /dev/null +++ b/src/game/entities/projectiles/icicle.ts @@ -0,0 +1,33 @@ +import { Container, Graphics } from 'pixi.js' +import type { Entity } from '@/game/core/EntityManager' +import type { TransformComp, ProjectileComp, RenderComp } from '@/game/components' +import { getWorldContainer } from '@/game/rendering/WorldContext' + +export function createIcicle( + entity: Entity, + startX: number, + startY: number, + targetId: number, + damage: number, + ownerId: number, +): void { + const container = new Container() + + const gfx = new Graphics() + gfx.poly([0, -8, -3, 0, 0, 5, 3, 0]) + gfx.fill({ color: 0xaaeeff }) + gfx.poly([0, -8, -3, 0, 0, 5, 3, 0]) + gfx.stroke({ color: 0x6ecbd5, width: 1 }) + + container.addChild(gfx) + container.x = startX + container.y = startY + getWorldContainer().addChild(container) + + Object.assign(entity, { + transform: { x: startX, y: startY, rotation: 0 } as TransformComp, + projectile: { speed: 320, targetId, damage, damageType: 'magic', ownerId, hitRadius: 10, projectileType: 'icicle' } as ProjectileComp, + render: { container, hpBar: null, label: null } as RenderComp, + }) + entity.tags.add('projectile') +} diff --git a/src/game/entities/projectiles/lightning.ts b/src/game/entities/projectiles/lightning.ts new file mode 100644 index 0000000..9b89eb7 --- /dev/null +++ b/src/game/entities/projectiles/lightning.ts @@ -0,0 +1,36 @@ +import { Container, Graphics } from 'pixi.js' +import type { Entity } from '@/game/core/EntityManager' +import type { TransformComp, ProjectileComp, RenderComp } from '@/game/components' +import { getWorldContainer } from '@/game/rendering/WorldContext' + +export function createLightning( + entity: Entity, + startX: number, + startY: number, + targetId: number, + damage: number, + ownerId: number, +): void { + const container = new Container() + + const gfx = new Graphics() + gfx.circle(0, 0, 5) + gfx.fill({ color: 0xffffff }) + gfx.circle(0, 0, 3) + gfx.fill({ color: 0xaaaaff }) + // small bolt shape + gfx.poly([2, -6, -1, 0, 2, 0, -2, 6]) + gfx.stroke({ color: 0xffd700, width: 1.5 }) + + container.addChild(gfx) + container.x = startX + container.y = startY + getWorldContainer().addChild(container) + + Object.assign(entity, { + transform: { x: startX, y: startY, rotation: 0 } as TransformComp, + projectile: { speed: 500, targetId, damage, damageType: 'magic', ownerId, hitRadius: 12, projectileType: 'lightning' } as ProjectileComp, + render: { container, hpBar: null, label: null } as RenderComp, + }) + entity.tags.add('projectile') +} diff --git a/src/game/entities/towers/archer.ts b/src/game/entities/towers/archer.ts new file mode 100644 index 0000000..6934607 --- /dev/null +++ b/src/game/entities/towers/archer.ts @@ -0,0 +1,107 @@ +import { Container, Graphics } from 'pixi.js' +import type { Entity } from '@/game/core/EntityManager' +import type { + TransformComp, TargetingComp, AttackComp, TeamComp, TowerComp, RenderComp, +} from '@/game/components' +import { getWorldContainer } from '@/game/rendering/WorldContext' +import { ISO_HALF_W, ISO_HALF_H } from '@/game/map/GridMap' + +const RANGE = 180 +export const ARCHER_COST = 50 + +export function createArcherTower( + entity: Entity, + tileX: number, + tileY: number, + worldX: number, + worldY: number, +): void { + const container = new Container() + const hw = ISO_HALF_W, hh = ISO_HALF_H + + // Iso stone base platform + const base = new Graphics() + // Top face of platform + base.poly([0, -hh, hw, 0, 0, hh, -hw, 0]) + base.fill({ color: 0x6a5040 }) + base.poly([0, -hh, hw, 0, 0, hh, -hw, 0]) + base.stroke({ color: 0xc9a14a, width: 1.5, alpha: 0.8 }) + // Left side face + base.poly([-hw, 0, 0, hh, 0, hh + 6, -hw, 6]) + base.fill({ color: 0x4a3828 }) + // Right side face + base.poly([0, hh, hw, 0, hw, 6, 0, hh + 6]) + base.fill({ color: 0x3a2818 }) + + // Stone tower body (rising upward from center) + const tower = new Graphics() + // Tower block + tower.rect(-10, -40, 20, 30) + tower.fill({ color: 0x6a5040 }) + tower.rect(-10, -40, 20, 30) + tower.stroke({ color: 0x8a6850, width: 1.5 }) + // Battlements + for (let i = 0; i < 3; i++) { + tower.rect(-10 + i * 7, -46, 5, 8) + tower.fill({ color: 0x6a5040 }) + tower.rect(-10 + i * 7, -46, 5, 8) + tower.stroke({ color: 0x8a6850, width: 1 }) + } + // Arrow slit window + tower.rect(-2, -34, 4, 8) + tower.fill({ color: 0x1a0f0a }) + // Rune glow on tower + tower.circle(0, -28, 4) + tower.fill({ color: 0xc9a14a, alpha: 0.75 }) + + // Archer figure on top + const archer = new Graphics() + // Body (small) + archer.rect(-3, -56, 6, 10) + archer.fill({ color: 0x884422 }) + // Head + archer.circle(0, -59, 3) + archer.fill({ color: 0xd4a574 }) + // Bow + archer.moveTo(-6, -57); archer.lineTo(-6, -52) + archer.stroke({ color: 0x8a6030, width: 2 }) + archer.moveTo(-6, -57); archer.lineTo(-1, -54) + archer.stroke({ color: 0xddaa44, width: 0.8, alpha: 0.8 }) + archer.moveTo(-6, -52); archer.lineTo(-1, -54) + archer.stroke({ color: 0xddaa44, width: 0.8, alpha: 0.8 }) + + // Range circle (hidden by default) + const rangeCircle = new Graphics() + rangeCircle.circle(0, 0, RANGE) + rangeCircle.stroke({ color: 0xc9a14a, width: 1, alpha: 0.3 }) + rangeCircle.fill({ color: 0xc9a14a, alpha: 0.04 }) + rangeCircle.visible = false + + container.addChild(base, tower, archer, rangeCircle) + + container.x = worldX + container.y = worldY + container.eventMode = 'static' + container.cursor = 'pointer' + container.on('pointerover', () => { rangeCircle.visible = true }) + container.on('pointerout', () => { rangeCircle.visible = false }) + + getWorldContainer().addChild(container) + + const transform: TransformComp = { x: worldX, y: worldY, rotation: 0 } + const targeting: TargetingComp = { range: RANGE, mode: 'first', targetId: null } + const attack: AttackComp = { + damage: 8, + damageType: 'physical', + cooldown: 0.9, + timer: 0, + projectileType: 'arrow', + } + const team: TeamComp = { team: 'player' } + const towerComp: TowerComp = { tileX, tileY, tier: 1, towerId: 'archer', totalCost: ARCHER_COST, leyLineBuff: false } + const render: RenderComp = { container, hpBar: null, label: null } + + Object.assign(entity, { transform, targeting, attack, team, towerComp, render }) + entity.tags.add('tower') + entity.tags.add('unit') +} diff --git a/src/game/entities/towers/cryomancer.ts b/src/game/entities/towers/cryomancer.ts new file mode 100644 index 0000000..0cce59d --- /dev/null +++ b/src/game/entities/towers/cryomancer.ts @@ -0,0 +1,97 @@ +import { Container, Graphics } from 'pixi.js' +import type { Entity } from '@/game/core/EntityManager' +import type { TransformComp, TargetingComp, AttackComp, TeamComp, TowerComp, RenderComp } from '@/game/components' +import { getWorldContainer } from '@/game/rendering/WorldContext' +import { ISO_HALF_W, ISO_HALF_H } from '@/game/map/GridMap' + +export const CRYO_COST = 90 +export const CRYO_RANGE = 160 + +export function createCryomancer(entity: Entity, tileX: number, tileY: number, wx: number, wy: number): void { + const container = new Container() + const hw = ISO_HALF_W, hh = ISO_HALF_H + + // Iso ice crystal base + const base = new Graphics() + base.poly([0, -hh, hw, 0, 0, hh, -hw, 0]) + base.fill({ color: 0x1a4a6a }) + base.poly([0, -hh, hw, 0, 0, hh, -hw, 0]) + base.stroke({ color: 0x6ecbd5, width: 1.5, alpha: 0.9 }) + // Left side — icy blue-dark + base.poly([-hw, 0, 0, hh, 0, hh + 6, -hw, 6]) + base.fill({ color: 0x0a1a3a }) + // Right side + base.poly([0, hh, hw, 0, hw, 6, 0, hh + 6]) + base.fill({ color: 0x081228 }) + // Ice facet lines on top + base.moveTo(-16, -2); base.lineTo(0, -hh); base.lineTo(16, -2) + base.stroke({ color: 0x9eeeff, width: 0.8, alpha: 0.5 }) + base.moveTo(-hw, 0); base.lineTo(0, 0); base.lineTo(hw, 0) + base.stroke({ color: 0x9eeeff, width: 0.6, alpha: 0.35 }) + + // Ice tower body + const tower = new Graphics() + tower.rect(-9, -44, 18, 34) + tower.fill({ color: 0x1a3a5a }) + tower.rect(-9, -44, 18, 34) + tower.stroke({ color: 0x2a6a8a, width: 1.5 }) + // Ice facets on tower face + tower.moveTo(-9, -30); tower.lineTo(0, -32); tower.lineTo(9, -30) + tower.stroke({ color: 0x6ecbd5, width: 1, alpha: 0.6 }) + tower.moveTo(-9, -20); tower.lineTo(0, -22); tower.lineTo(9, -20) + tower.stroke({ color: 0x6ecbd5, width: 0.8, alpha: 0.4 }) + + // Pointed frost spire + const spire = new Graphics() + // Main spire + spire.poly([0, -62, -6, -44, 6, -44]) + spire.fill({ color: 0x4a9aaa }) + spire.stroke({ color: 0x6ecbd5, width: 1 }) + // Secondary spire left + spire.poly([-5, -52, -10, -44, -2, -44]) + spire.fill({ color: 0x3a7a8a, alpha: 0.9 }) + // Secondary spire right + spire.poly([5, -52, 10, -44, 2, -44]) + spire.fill({ color: 0x3a7a8a, alpha: 0.9 }) + // Frost crystal at spire top + spire.circle(0, -62, 4) + spire.fill({ color: 0x9eeeff, alpha: 0.9 }) + spire.poly([0, -67, 4, -62, 0, -57, -4, -62]) + spire.fill({ color: 0xffffff, alpha: 0.85 }) + // Frost glow + spire.circle(0, -62, 8) + spire.stroke({ color: 0x6ecbd5, width: 1.5, alpha: 0.5 }) + + // Frost crystals on base + const crystals = new Graphics() + crystals.poly([-14, -6, -17, -14, -11, -6]) + crystals.fill({ color: 0x6ecbd5, alpha: 0.8 }) + crystals.poly([14, -4, 17, -12, 11, -4]) + crystals.fill({ color: 0x6ecbd5, alpha: 0.7 }) + + // Range circle + const rangeCircle = new Graphics() + rangeCircle.circle(0, 0, CRYO_RANGE) + rangeCircle.stroke({ color: 0x6ecbd5, width: 1, alpha: 0.3 }) + rangeCircle.fill({ color: 0x6ecbd5, alpha: 0.04 }) + rangeCircle.visible = false + + container.addChild(base, tower, spire, crystals, rangeCircle) + container.x = wx + container.y = wy + container.eventMode = 'static' + container.cursor = 'pointer' + container.on('pointerover', () => { rangeCircle.visible = true }) + container.on('pointerout', () => { rangeCircle.visible = false }) + getWorldContainer().addChild(container) + + Object.assign(entity, { + transform: { x: wx, y: wy, rotation: 0 } as TransformComp, + targeting: { range: CRYO_RANGE, mode: 'first', targetId: null } as TargetingComp, + attack: { damage: 6, damageType: 'magic', cooldown: 1.1, timer: 0, projectileType: 'icicle' } as AttackComp, + team: { team: 'player' } as TeamComp, + towerComp: { tileX, tileY, tier: 1, towerId: 'cryomancer', totalCost: CRYO_COST, leyLineBuff: false } as TowerComp, + render: { container, hpBar: null, label: null } as RenderComp, + }) + entity.tags.add('tower'); entity.tags.add('unit') +} diff --git a/src/game/entities/towers/pyromancer.ts b/src/game/entities/towers/pyromancer.ts new file mode 100644 index 0000000..6a6ca91 --- /dev/null +++ b/src/game/entities/towers/pyromancer.ts @@ -0,0 +1,89 @@ +import { Container, Graphics } from 'pixi.js' +import type { Entity } from '@/game/core/EntityManager' +import type { TransformComp, TargetingComp, AttackComp, TeamComp, TowerComp, RenderComp } from '@/game/components' +import { getWorldContainer } from '@/game/rendering/WorldContext' +import { ISO_HALF_W, ISO_HALF_H } from '@/game/map/GridMap' + +export const PYROMANCER_COST = 80 +export const PYRO_RANGE = 150 +export const PYRO_AOE_RADIUS = 48 + +export function createPyromancer(entity: Entity, tileX: number, tileY: number, wx: number, wy: number): void { + const container = new Container() + const hw = ISO_HALF_W, hh = ISO_HALF_H + + // Iso stone base — fiery orange-tinted + const base = new Graphics() + base.poly([0, -hh, hw, 0, 0, hh, -hw, 0]) + base.fill({ color: 0x6a3010 }) + base.poly([0, -hh, hw, 0, 0, hh, -hw, 0]) + base.stroke({ color: 0xe8702a, width: 1.5, alpha: 0.85 }) + base.poly([-hw, 0, 0, hh, 0, hh + 6, -hw, 6]) + base.fill({ color: 0x3a1a0a }) + base.poly([0, hh, hw, 0, hw, 6, 0, hh + 6]) + base.fill({ color: 0x2a1008 }) + // Lava cracks in base + base.moveTo(-8, -2); base.lineTo(-4, 2); base.lineTo(2, 0) + base.stroke({ color: 0xff6600, width: 1, alpha: 0.55 }) + base.moveTo(6, -4); base.lineTo(10, 0) + base.stroke({ color: 0xff6600, width: 1, alpha: 0.45 }) + + // Spire body + const tower = new Graphics() + tower.rect(-10, -42, 20, 32) + tower.fill({ color: 0x5a2a10 }) + tower.rect(-10, -42, 20, 32) + tower.stroke({ color: 0x7a3a18, width: 1.5 }) + // Battlements + for (let i = 0; i < 3; i++) { + tower.rect(-10 + i * 7, -48, 5, 8) + tower.fill({ color: 0x5a2a10 }) + tower.stroke({ color: 0x7a3a18, width: 1 }) + } + // Fire rune on tower face + tower.circle(0, -32, 5) + tower.fill({ color: 0xff4400, alpha: 0.65 }) + tower.circle(0, -32, 3) + tower.fill({ color: 0xff8800, alpha: 0.8 }) + + // Flaming spire tip + const flame = new Graphics() + // Outer flame + flame.poly([0, -62, -6, -52, -8, -44, 0, -48, 8, -44, 6, -52]) + flame.fill({ color: 0xff4400, alpha: 0.85 }) + // Middle flame + flame.poly([0, -60, -4, -52, -5, -46, 0, -50, 5, -46, 4, -52]) + flame.fill({ color: 0xff8800, alpha: 0.9 }) + // Inner bright flame + flame.poly([0, -58, -2, -52, 0, -48, 2, -52]) + flame.fill({ color: 0xffdd00, alpha: 0.95 }) + // Flame glow ring + flame.circle(0, -52, 8) + flame.stroke({ color: 0xff6600, width: 2, alpha: 0.4 }) + + // Range circle + const rangeCircle = new Graphics() + rangeCircle.circle(0, 0, PYRO_RANGE) + rangeCircle.stroke({ color: 0xe8702a, width: 1, alpha: 0.3 }) + rangeCircle.fill({ color: 0xe8702a, alpha: 0.04 }) + rangeCircle.visible = false + + container.addChild(base, tower, flame, rangeCircle) + container.x = wx + container.y = wy + container.eventMode = 'static' + container.cursor = 'pointer' + container.on('pointerover', () => { rangeCircle.visible = true }) + container.on('pointerout', () => { rangeCircle.visible = false }) + getWorldContainer().addChild(container) + + Object.assign(entity, { + transform: { x: wx, y: wy, rotation: 0 } as TransformComp, + targeting: { range: PYRO_RANGE, mode: 'first', targetId: null } as TargetingComp, + attack: { damage: 12, damageType: 'magic', cooldown: 1.6, timer: 0, projectileType: 'fireball' } as AttackComp, + team: { team: 'player' } as TeamComp, + towerComp: { tileX, tileY, tier: 1, towerId: 'pyromancer', totalCost: PYROMANCER_COST, leyLineBuff: false } as TowerComp, + render: { container, hpBar: null, label: null } as RenderComp, + }) + entity.tags.add('tower'); entity.tags.add('unit') +} diff --git a/src/game/entities/towers/stormcaller.ts b/src/game/entities/towers/stormcaller.ts new file mode 100644 index 0000000..078fd30 --- /dev/null +++ b/src/game/entities/towers/stormcaller.ts @@ -0,0 +1,94 @@ +import { Container, Graphics } from 'pixi.js' +import type { Entity } from '@/game/core/EntityManager' +import type { TransformComp, TargetingComp, AttackComp, TeamComp, TowerComp, RenderComp } from '@/game/components' +import { getWorldContainer } from '@/game/rendering/WorldContext' +import { ISO_HALF_W, ISO_HALF_H } from '@/game/map/GridMap' + +export const STORM_COST = 110 +export const STORM_RANGE = 200 + +export function createStormcaller(entity: Entity, tileX: number, tileY: number, wx: number, wy: number): void { + const container = new Container() + const hw = ISO_HALF_W, hh = ISO_HALF_H + + // Iso dark stone base + const base = new Graphics() + base.poly([0, -hh, hw, 0, 0, hh, -hw, 0]) + base.fill({ color: 0x2a2a4a }) + base.poly([0, -hh, hw, 0, 0, hh, -hw, 0]) + base.stroke({ color: 0xffd700, width: 2, alpha: 0.9 }) + // Left side + base.poly([-hw, 0, 0, hh, 0, hh + 6, -hw, 6]) + base.fill({ color: 0x1a1a2a }) + // Right side + base.poly([0, hh, hw, 0, hw, 6, 0, hh + 6]) + base.fill({ color: 0x121220 }) + // Lightning rune lines on base + base.moveTo(-10, -4); base.lineTo(-2, -8); base.lineTo(-6, -12); base.lineTo(2, -16) + base.stroke({ color: 0xffd700, width: 1, alpha: 0.55 }) + base.moveTo(10, -2); base.lineTo(4, -8); base.lineTo(8, -12) + base.stroke({ color: 0xffd700, width: 1, alpha: 0.4 }) + + // Obelisk body + const obelisk = new Graphics() + obelisk.rect(-8, -50, 16, 40) + obelisk.fill({ color: 0x2a2a4a }) + obelisk.rect(-8, -50, 16, 40) + obelisk.stroke({ color: 0x4a4a7a, width: 1.5 }) + // Obelisk pyramid cap + obelisk.poly([0, -60, -8, -50, 8, -50]) + obelisk.fill({ color: 0x5555aa }) + obelisk.stroke({ color: 0x8888cc, width: 1 }) + // Rune glyphs on obelisk faces + obelisk.moveTo(-6, -44); obelisk.lineTo(-2, -38); obelisk.lineTo(2, -42); obelisk.lineTo(6, -36) + obelisk.stroke({ color: 0xffd700, width: 1, alpha: 0.65 }) + obelisk.moveTo(-5, -28); obelisk.lineTo(0, -24); obelisk.lineTo(5, -28) + obelisk.stroke({ color: 0xffd700, width: 1, alpha: 0.5 }) + + // Lightning orb at top + const orb = new Graphics() + orb.circle(0, -62, 8) + orb.fill({ color: 0xffd700, alpha: 0.75 }) + orb.circle(0, -62, 5) + orb.fill({ color: 0xffffff, alpha: 0.9 }) + orb.circle(0, -62, 2) + orb.fill({ color: 0x8888ff, alpha: 0.95 }) + // Orb glow ring + orb.circle(0, -62, 12) + orb.stroke({ color: 0xffd700, width: 2, alpha: 0.4 }) + orb.circle(0, -62, 18) + orb.stroke({ color: 0xffd700, width: 1, alpha: 0.2 }) + // Static discharge sparks + orb.moveTo(0, -62); orb.lineTo(-14, -70) + orb.stroke({ color: 0xffd700, width: 1, alpha: 0.5 }) + orb.moveTo(0, -62); orb.lineTo(14, -68) + orb.stroke({ color: 0xffd700, width: 1, alpha: 0.5 }) + orb.moveTo(0, -62); orb.lineTo(0, -76) + orb.stroke({ color: 0xffffff, width: 1, alpha: 0.4 }) + + // Range circle + const rangeCircle = new Graphics() + rangeCircle.circle(0, 0, STORM_RANGE) + rangeCircle.stroke({ color: 0xffd700, width: 1, alpha: 0.3 }) + rangeCircle.fill({ color: 0xffd700, alpha: 0.03 }) + rangeCircle.visible = false + + container.addChild(base, obelisk, orb, rangeCircle) + container.x = wx + container.y = wy + container.eventMode = 'static' + container.cursor = 'pointer' + container.on('pointerover', () => { rangeCircle.visible = true }) + container.on('pointerout', () => { rangeCircle.visible = false }) + getWorldContainer().addChild(container) + + Object.assign(entity, { + transform: { x: wx, y: wy, rotation: 0 } as TransformComp, + targeting: { range: STORM_RANGE, mode: 'first', targetId: null } as TargetingComp, + attack: { damage: 18, damageType: 'magic', cooldown: 2.0, timer: 0, projectileType: 'lightning' } as AttackComp, + team: { team: 'player' } as TeamComp, + towerComp: { tileX, tileY, tier: 1, towerId: 'stormcaller', totalCost: STORM_COST, leyLineBuff: false } as TowerComp, + render: { container, hpBar: null, label: null } as RenderComp, + }) + entity.tags.add('tower'); entity.tags.add('unit') +} diff --git a/src/game/map/GridMap.ts b/src/game/map/GridMap.ts new file mode 100644 index 0000000..fae8475 --- /dev/null +++ b/src/game/map/GridMap.ts @@ -0,0 +1,99 @@ +export const TILE_SIZE = 64 // keep for legacy +export const ISO_HALF_W = 32 +export const ISO_HALF_H = 16 + +export type TileType = 'buildable' | 'path' | 'blocked' | 'ley_line' | 'nexus' | 'spawn' + +export interface Tile { + x: number + y: number + type: TileType + towerEntityId?: number +} + +export class GridMap { + readonly cols: number + readonly rows: number + readonly isoOriginX: number + private tiles: Tile[] + + constructor(cols: number, rows: number) { + this.cols = cols + this.rows = rows + this.isoOriginX = rows * ISO_HALF_W + this.tiles = Array.from({ length: cols * rows }, (_, i) => ({ + x: i % cols, + y: Math.floor(i / cols), + type: 'buildable', + })) + } + + get(x: number, y: number): Tile | null { + if (x < 0 || y < 0 || x >= this.cols || y >= this.rows) return null + return this.tiles[y * this.cols + x] + } + + set(x: number, y: number, type: TileType): void { + const t = this.get(x, y) + if (t) t.type = type + } + + isWalkable(x: number, y: number): boolean { + const t = this.get(x, y) + return t !== null && (t.type === 'path' || t.type === 'spawn' || t.type === 'nexus') + } + + isBuildable(x: number, y: number): boolean { + const t = this.get(x, y) + return t !== null && (t.type === 'buildable' || t.type === 'ley_line') && !t.towerEntityId + } + + /** ISO screen coords → tile coords */ + screenToTile(sx: number, sy: number): { x: number; y: number } { + const lx = sx - this.isoOriginX + const ly = sy + return { + x: Math.floor((lx / ISO_HALF_W + ly / ISO_HALF_H) / 2), + y: Math.floor((ly / ISO_HALF_H - lx / ISO_HALF_W) / 2), + } + } + + /** Tile coords → ISO top-left (top vertex of diamond) */ + tileToScreen(tx: number, ty: number): { x: number; y: number } { + return { + x: (tx - ty) * ISO_HALF_W + this.isoOriginX, + y: (tx + ty) * ISO_HALF_H - ISO_HALF_H, + } + } + + /** Center of tile in ISO screen coords */ + tileCenter(tx: number, ty: number): { x: number; y: number } { + return { + x: (tx - ty) * ISO_HALF_W + this.isoOriginX, + y: (tx + ty) * ISO_HALF_H, + } + } + + isoMapWidth(): number { return (this.cols + this.rows) * ISO_HALF_W } + isoMapHeight(): number { return (this.cols + this.rows) * ISO_HALF_H + ISO_HALF_H } + + loadFromLayout(layout: string[]): void { + for (let row = 0; row < layout.length && row < this.rows; row++) { + for (let col = 0; col < layout[row].length && col < this.cols; col++) { + const ch = layout[row][col] + const type: TileType = + ch === 'P' ? 'path' + : ch === 'S' ? 'spawn' + : ch === 'N' ? 'nexus' + : ch === 'L' ? 'ley_line' + : ch === 'X' ? 'blocked' + : 'buildable' + this.set(col, row, type) + } + } + } + + allTiles(): Tile[] { + return this.tiles + } +} diff --git a/src/game/map/LeyLines.ts b/src/game/map/LeyLines.ts new file mode 100644 index 0000000..e1ed9db --- /dev/null +++ b/src/game/map/LeyLines.ts @@ -0,0 +1,29 @@ +import { GridMap } from './GridMap' + +export interface LeyLineSegment { + x1: number; y1: number + x2: number; y2: number +} + +/** Returns pairs of connected ley-line tiles for visual rendering */ +export function getLeyLineSegments(map: GridMap): LeyLineSegment[] { + const segments: LeyLineSegment[] = [] + const dirs = [[1, 0], [0, 1]] + + for (const tile of map.allTiles()) { + if (tile.type !== 'ley_line') continue + for (const [dx, dy] of dirs) { + const neighbor = map.get(tile.x + dx, tile.y + dy) + if (neighbor?.type === 'ley_line') { + segments.push({ x1: tile.x, y1: tile.y, x2: neighbor.x, y2: neighbor.y }) + } + } + } + return segments +} + +/** Check if a tile is adjacent to or on a ley line (for tower bonuses) */ +export function isNearLeyLine(map: GridMap, tx: number, ty: number): boolean { + const dirs = [[0,0],[1,0],[-1,0],[0,1],[0,-1]] + return dirs.some(([dx, dy]) => map.get(tx + dx, ty + dy)?.type === 'ley_line') +} diff --git a/src/game/map/Pathfinding.ts b/src/game/map/Pathfinding.ts new file mode 100644 index 0000000..2a8851e --- /dev/null +++ b/src/game/map/Pathfinding.ts @@ -0,0 +1,49 @@ +import type { PathRequest, PathResult } from '@/workers/pathfinding.worker' +import { GridMap } from './GridMap' + +type PendingCallback = (path: { x: number; y: number }[] | null) => void + +let worker: Worker | null = null +let reqId = 0 +const pending = new Map() + +function getWorker(): Worker { + if (!worker) { + worker = new Worker( + new URL('@/workers/pathfinding.worker.ts', import.meta.url), + { type: 'module' } + ) + worker.onmessage = (e: MessageEvent) => { + const cb = pending.get(e.data.id) + if (cb) { + pending.delete(e.data.id) + cb(e.data.path) + } + } + } + return worker +} + +export function findPath( + map: GridMap, + start: { x: number; y: number }, + goal: { x: number; y: number } +): Promise<{ x: number; y: number }[] | null> { + return new Promise((resolve) => { + const id = ++reqId + pending.set(id, resolve) + + const walkable: boolean[][] = Array.from({ length: map.rows }, (_, row) => + Array.from({ length: map.cols }, (_, col) => map.isWalkable(col, row)) + ) + + const req: PathRequest = { id, grid: walkable, cols: map.cols, rows: map.rows, start, goal } + getWorker().postMessage(req) + }) +} + +export function terminatePathWorker(): void { + worker?.terminate() + worker = null + pending.clear() +} diff --git a/src/game/rendering/MapRenderer.ts b/src/game/rendering/MapRenderer.ts new file mode 100644 index 0000000..7e22007 --- /dev/null +++ b/src/game/rendering/MapRenderer.ts @@ -0,0 +1,181 @@ +import { Container, Graphics, Text, TextStyle } from 'pixi.js' +import { GridMap, ISO_HALF_W, ISO_HALF_H, type TileType } from '@/game/map/GridMap' +import { getLeyLineSegments } from '@/game/map/LeyLines' + +const TILE_TOP: Record = { + buildable: 0x2d5a1e, + path: 0x6b4226, + blocked: 0x1a1a28, + ley_line: 0x1a3a5c, + nexus: 0x2a1060, + spawn: 0x601010, +} +const TILE_SIDE_L: Record = { + buildable: 0x1e3e14, + path: 0x4a2e18, + blocked: 0x111120, + ley_line: 0x102840, + nexus: 0x1a0840, + spawn: 0x400808, +} +const TILE_SIDE_R: Record = { + buildable: 0x163010, + path: 0x3a2010, + blocked: 0x0c0c18, + ley_line: 0x0a1e30, + nexus: 0x140630, + spawn: 0x300606, +} +const TILE_EDGE: Record = { + buildable: 0x3a7a28, + path: 0x8a5a30, + blocked: 0x2a2a38, + ley_line: 0x2a5a8a, + nexus: 0x3020a0, + spawn: 0x801818, +} + +const SIDE_DEPTH = 8 // px depth of 3D side faces + +export class MapRenderer { + readonly container = new Container() + private leyGfx = new Graphics() + private debugContainer = new Container() + private map: GridMap | null = null + private _debugPath: { x: number; y: number }[] = [] + + constructor() { + this.container.addChild(this.leyGfx) + this.container.addChild(this.debugContainer) + } + + load(map: GridMap): void { + this.map = map + this._drawTiles() + this._drawLeyLines() + } + + showPath(path: { x: number; y: number }[]): void { + this._debugPath = path + this._drawDebug() + } + + private _drawTiles(): void { + if (!this.map) return + const hw = ISO_HALF_W, hh = ISO_HALF_H, sd = SIDE_DEPTH + + const sideLGfx = new Graphics() + const sideRGfx = new Graphics() + const topGfx = new Graphics() + const decoGfx = new Graphics() + + // Draw in back-to-front order: sort tiles by (tx + ty) ascending + const sorted = [...this.map.allTiles()].sort((a, b) => (a.x + a.y) - (b.x + b.y)) + + for (const tile of sorted) { + const { x: cx, y: cy } = this.map.tileCenter(tile.x, tile.y) + + // Left side face (south-west) + sideLGfx.poly([cx - hw, cy, cx, cy + hh, cx, cy + hh + sd, cx - hw, cy + sd]) + sideLGfx.fill({ color: TILE_SIDE_L[tile.type] }) + + // Right side face (south-east) + sideRGfx.poly([cx, cy + hh, cx + hw, cy, cx + hw, cy + sd, cx, cy + hh + sd]) + sideRGfx.fill({ color: TILE_SIDE_R[tile.type] }) + + // Top diamond face + topGfx.poly([cx, cy - hh, cx + hw, cy, cx, cy + hh, cx - hw, cy]) + topGfx.fill({ color: TILE_TOP[tile.type] }) + topGfx.poly([cx, cy - hh, cx + hw, cy, cx, cy + hh, cx - hw, cy]) + topGfx.stroke({ color: TILE_EDGE[tile.type], width: 1, alpha: 0.5 }) + + // Decorations + if (tile.type === 'spawn') { + decoGfx.circle(cx, cy, 10) + decoGfx.fill({ color: 0xff4444, alpha: 0.7 }) + decoGfx.circle(cx, cy, 10) + decoGfx.stroke({ color: 0xff8888, width: 1.5 }) + } + if (tile.type === 'nexus') { + decoGfx.circle(cx, cy, 14) + decoGfx.fill({ color: 0x8844ff, alpha: 0.8 }) + decoGfx.circle(cx, cy, 14) + decoGfx.stroke({ color: 0xbb88ff, width: 2 }) + // Nexus gem + decoGfx.poly([cx, cy - 10, cx + 8, cy, cx, cy + 10, cx - 8, cy]) + decoGfx.fill({ color: 0xcc88ff, alpha: 0.9 }) + } + } + + this.container.addChildAt(sideLGfx, 0) + this.container.addChildAt(sideRGfx, 1) + this.container.addChildAt(topGfx, 2) + this.container.addChildAt(decoGfx, 3) + } + + private _drawLeyLines(): void { + if (!this.map) return + this.leyGfx.clear() + const segments = getLeyLineSegments(this.map) + + for (const { x1, y1, x2, y2 } of segments) { + const { x: sx1, y: sy1 } = this.map.tileCenter(x1, y1) + const { x: sx2, y: sy2 } = this.map.tileCenter(x2, y2) + this.leyGfx.moveTo(sx1, sy1) + this.leyGfx.lineTo(sx2, sy2) + this.leyGfx.stroke({ color: 0x6ecbd5, width: 8, alpha: 0.12 }) + this.leyGfx.moveTo(sx1, sy1) + this.leyGfx.lineTo(sx2, sy2) + this.leyGfx.stroke({ color: 0x6ecbd5, width: 3, alpha: 0.6 }) + } + + for (const tile of this.map.allTiles()) { + if (tile.type !== 'ley_line') continue + const { x: cx, y: cy } = this.map.tileCenter(tile.x, tile.y) + this.leyGfx.circle(cx, cy, 5) + this.leyGfx.fill({ color: 0x6ecbd5, alpha: 0.9 }) + } + } + + private _drawDebug(): void { + this.debugContainer.removeChildren() + if (this._debugPath.length === 0 || !this.map) return + + const gfx = new Graphics() + const pts = this._debugPath + + for (let i = 1; i < pts.length; i++) { + const { x: x1, y: y1 } = this.map.tileCenter(pts[i - 1].x, pts[i - 1].y) + const { x: x2, y: y2 } = this.map.tileCenter(pts[i].x, pts[i].y) + gfx.moveTo(x1, y1) + gfx.lineTo(x2, y2) + gfx.stroke({ color: 0xffdd44, width: 2, alpha: 0.7 }) + } + + for (let i = 0; i < pts.length; i++) { + const { x, y } = this.map.tileCenter(pts[i].x, pts[i].y) + const isEnd = i === 0 || i === pts.length - 1 + gfx.circle(x, y, isEnd ? 8 : 4) + gfx.fill({ + color: i === 0 ? 0xff4444 : i === pts.length - 1 ? 0x44ff88 : 0xffdd44, + alpha: 0.9, + }) + } + + for (let i = 0; i < pts.length; i += 4) { + const { x, y } = this.map.tileCenter(pts[i].x, pts[i].y) + const label = new Text({ + text: String(i), + style: new TextStyle({ fill: 0xffffff, fontSize: 9, fontFamily: 'monospace' }), + }) + label.x = x + 6 + label.y = y - 6 + this.debugContainer.addChild(label) + } + this.debugContainer.addChild(gfx) + } + + destroy(): void { + this.container.destroy({ children: true }) + } +} diff --git a/src/game/rendering/PixiRoot.ts b/src/game/rendering/PixiRoot.ts new file mode 100644 index 0000000..20e7f25 --- /dev/null +++ b/src/game/rendering/PixiRoot.ts @@ -0,0 +1,82 @@ +import { Application, Container } from 'pixi.js' + +export interface GameLayers { + bg: Container + terrain: Container + fxBelow: Container + units: Container + fxAbove: Container + ui: Container +} + +let app: Application | null = null +let _canvas: HTMLCanvasElement | null = null +export let layers: GameLayers | null = null + +/** Pass the wrapper DIV — PixiJS creates a fresh canvas each time to avoid WebGL context reuse bugs */ +export async function initPixi(wrapper: HTMLElement): Promise { + // fresh canvas every init so WebGL context is never reused after destroy + const canvas = document.createElement('canvas') + canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block;' + wrapper.appendChild(canvas) + _canvas = canvas + + const _app = new Application() + app = _app + + await _app.init({ + canvas, + resizeTo: wrapper, + background: '#0E1220', + antialias: true, + resolution: window.devicePixelRatio || 1, + autoDensity: true, + preference: 'webgl', + }) + + if (app !== _app) { + canvas.remove() + try { _app.destroy(false, { children: true }) } catch { /* partial init */ } + throw new Error('PixiJS init cancelled') + } + + layers = { + bg: new Container(), + terrain: new Container(), + fxBelow: new Container(), + units: new Container(), + fxAbove: new Container(), + ui: new Container(), + } + + for (const layer of Object.values(layers)) { + _app.stage.addChild(layer) + } + + return _app +} + +export function destroyPixi(): void { + if (!app) return + const _app = app + app = null + layers = null + _canvas?.remove() + _canvas = null + try { _app.destroy(false, { children: true }) } catch { /* ignore partial init */ } +} + +export function getApp(): Application { + if (!app) throw new Error('PixiJS not initialized') + return app +} + +export function getSize(): { width: number; height: number } { + return app + ? { width: app.renderer.width, height: app.renderer.height } + : { width: 0, height: 0 } +} + +export function getCanvas(): HTMLCanvasElement | null { + return _canvas +} diff --git a/src/game/rendering/WorldContext.ts b/src/game/rendering/WorldContext.ts new file mode 100644 index 0000000..0379ed7 --- /dev/null +++ b/src/game/rendering/WorldContext.ts @@ -0,0 +1,12 @@ +import { Container } from 'pixi.js' + +let _world: Container | null = null + +export function setWorldContainer(c: Container | null): void { + _world = c +} + +export function getWorldContainer(): Container { + if (!_world) throw new Error('World container not set') + return _world +} diff --git a/src/game/rendering/camera.ts b/src/game/rendering/camera.ts new file mode 100644 index 0000000..ee70914 --- /dev/null +++ b/src/game/rendering/camera.ts @@ -0,0 +1,115 @@ +import { Container } from 'pixi.js' +import { clamp } from '@/lib/math' + +export interface CameraBounds { + minX: number; maxX: number + minY: number; maxY: number + minZoom: number; maxZoom: number +} + +export class Camera { + x = 0 + y = 0 + zoom = 1 + private root: Container + private bounds: CameraBounds + private isDragging = false + private lastPointer = { x: 0, y: 0 } + private _shakeIntensity = 0 + private _shakeDuration = 0 + private _shakeTime = 0 + private _shakeX = 0 + private _shakeY = 0 + + constructor(root: Container, bounds: CameraBounds) { + this.root = root + this.bounds = bounds + } + + shake(intensity: number, duration: number): void { + this._shakeIntensity = intensity + this._shakeDuration = duration + this._shakeTime = duration + } + + update(dt: number): void { + if (this._shakeTime <= 0) { + if (this._shakeX !== 0 || this._shakeY !== 0) { + this._shakeX = 0 + this._shakeY = 0 + this._apply() + } + return + } + this._shakeTime = Math.max(0, this._shakeTime - dt) + const progress = this._shakeTime / this._shakeDuration + const amp = this._shakeIntensity * progress + this._shakeX = (Math.random() * 2 - 1) * amp + this._shakeY = (Math.random() * 2 - 1) * amp + this._apply() + } + + pan(dx: number, dy: number): void { + this.x = clamp(this.x + dx, this.bounds.minX, this.bounds.maxX) + this.y = clamp(this.y + dy, this.bounds.minY, this.bounds.maxY) + this._apply() + } + + zoomAt(factor: number, screenX: number, screenY: number): void { + const oldZoom = this.zoom + this.zoom = clamp(this.zoom * factor, this.bounds.minZoom, this.bounds.maxZoom) + const scale = this.zoom / oldZoom + this.x = screenX - (screenX - this.x) * scale + this.y = screenY - (screenY - this.y) * scale + this._apply() + } + + screenToWorld(sx: number, sy: number): { x: number; y: number } { + return { + x: (sx - this.x) / this.zoom, + y: (sy - this.y) / this.zoom, + } + } + + attachPointerEvents(el: HTMLElement): () => void { + const onDown = (e: PointerEvent) => { + if (e.button === 1 || e.button === 2) { + this.isDragging = true + this.lastPointer = { x: e.clientX, y: e.clientY } + el.setPointerCapture(e.pointerId) + } + } + const onMove = (e: PointerEvent) => { + if (!this.isDragging) return + const dx = e.clientX - this.lastPointer.x + const dy = e.clientY - this.lastPointer.y + this.lastPointer = { x: e.clientX, y: e.clientY } + this.pan(dx, dy) + } + const onUp = () => { this.isDragging = false } + const onWheel = (e: WheelEvent) => { + e.preventDefault() + const factor = e.deltaY < 0 ? 1.1 : 0.9 + const rect = el.getBoundingClientRect() + this.zoomAt(factor, e.clientX - rect.left, e.clientY - rect.top) + } + + el.addEventListener('pointerdown', onDown) + el.addEventListener('pointermove', onMove) + el.addEventListener('pointerup', onUp) + el.addEventListener('wheel', onWheel, { passive: false }) + + return () => { + el.removeEventListener('pointerdown', onDown) + el.removeEventListener('pointermove', onMove) + el.removeEventListener('pointerup', onUp) + el.removeEventListener('wheel', onWheel) + } + } + + private _apply(): void { + this.root.x = this.x + this._shakeX + this.root.y = this.y + this._shakeY + this.root.scale.set(this.zoom) + } +} diff --git a/src/game/systems/AttackSystem.ts b/src/game/systems/AttackSystem.ts new file mode 100644 index 0000000..ef3505d --- /dev/null +++ b/src/game/systems/AttackSystem.ts @@ -0,0 +1,39 @@ +import type { EntityManager } from '@/game/core/EntityManager' +import type { TransformComp, TargetingComp, AttackComp, RenderComp } from '@/game/components' +import { createArrow } from '@/game/entities/projectiles/arrow' +import { createFireball } from '@/game/entities/projectiles/fireball' +import { createIcicle } from '@/game/entities/projectiles/icicle' +import { createLightning } from '@/game/entities/projectiles/lightning' + +export function attackSystem(entities: EntityManager, dt: number): void { + for (const tower of entities.withTag('tower')) { + const t = tower.transform as TransformComp + const tgt = tower.targeting as TargetingComp + const atk = tower.attack as AttackComp + const ren = tower.render as RenderComp + if (!t || !tgt || !atk) continue + + atk.timer = Math.max(0, atk.timer - dt) + + if (tgt.targetId === null || atk.timer > 0) continue + + const target = entities.get(tgt.targetId) + if (!target || target.tags.has('dead')) { tgt.targetId = null; continue } + + const te = target.transform as TransformComp + if (!te) continue + + + const proj = entities.create(['projectile']) + const args: [typeof proj, number, number, number, number, number] = [proj, t.x, t.y, target.id, atk.damage, tower.id] + + switch (atk.projectileType) { + case 'fireball': createFireball(...args); break + case 'icicle': createIcicle(...args); break + case 'lightning': createLightning(...args); break + default: createArrow(...args); break + } + + atk.timer = atk.cooldown + } +} diff --git a/src/game/systems/DeathSystem.ts b/src/game/systems/DeathSystem.ts new file mode 100644 index 0000000..4cd4a67 --- /dev/null +++ b/src/game/systems/DeathSystem.ts @@ -0,0 +1,76 @@ +import { Graphics } from 'pixi.js' +import type { EntityManager } from '@/game/core/EntityManager' +import type { RenderComp, TowerComp, TransformComp } from '@/game/components' +import type { GridMap } from '@/game/map/GridMap' +import { getWorldContainer } from '@/game/rendering/WorldContext' + +function spawnDeathParticles(x: number, y: number, color: number): void { + let world = null + try { world = getWorldContainer() } catch { return } + if (!world) return + + const gfx = new Graphics() + const count = 6 + const angles: number[] = [] + const speeds: number[] = [] + for (let i = 0; i < count; i++) { + angles.push((i / count) * Math.PI * 2 + Math.random() * 0.5) + speeds.push(40 + Math.random() * 40) + } + world.addChild(gfx) + + let life = 0.35 + const tick = () => { + life -= 1 / 60 + if (life <= 0) { gfx.destroy(); return } + const t = 1 - life / 0.35 + gfx.clear() + for (let i = 0; i < count; i++) { + const dist = speeds[i] * t + const px = x + Math.cos(angles[i]) * dist + const py = y + Math.sin(angles[i]) * dist + const r = 3 * (1 - t) + gfx.circle(px, py, Math.max(0.5, r)) + gfx.fill({ color, alpha: life / 0.35 }) + } + requestAnimationFrame(tick) + } + requestAnimationFrame(tick) +} + +const ENEMY_COLORS: Record = { + goblin: 0x44aa44, + orc: 0x228822, + warg: 0x886644, + wraith: 0x9966ff, + troll: 0x557755, + golem: 0x8899aa, +} + +export function deathSystem(entities: EntityManager, map: GridMap): void { + for (const entity of entities.withTag('dead')) { + const r = entity.render as RenderComp + const t = entity.transform as TransformComp | undefined + + if (r?.container && t && entity.tags.has('enemy')) { + let color = 0xffffff + for (const [tag, c] of Object.entries(ENEMY_COLORS)) { + if (entity.tags.has(tag)) { color = c; break } + } + spawnDeathParticles(t.x, t.y, color) + } + + if (r?.container) { + r.container.destroy({ children: true }) + } + + // free tower tile + const tc = entity.towerComp as TowerComp + if (tc) { + const tile = map.get(tc.tileX, tc.tileY) + if (tile) tile.towerEntityId = undefined + } + + entities.destroy(entity.id) + } +} diff --git a/src/game/systems/HeroSystem.ts b/src/game/systems/HeroSystem.ts new file mode 100644 index 0000000..a629c19 --- /dev/null +++ b/src/game/systems/HeroSystem.ts @@ -0,0 +1,61 @@ +import type { EntityManager } from '@/game/core/EntityManager' +import type { TransformComp, RenderComp } from '@/game/components' +import type { HeroComp } from '@/game/components/Hero' + +const XP_PER_LEVEL = [0, 120, 280, 500, 800] +const STOP_DIST = 4 + +export function heroSystem(entities: EntityManager, dt: number): void { + for (const entity of entities.withTag('hero')) { + const t = entity.transform as TransformComp + const h = entity.hero as HeroComp + const r = entity.render as RenderComp + if (!t || !h) continue + + // Tick spell cooldowns + for (const spell of h.spells) { + if (spell.timer > 0) spell.timer = Math.max(0, spell.timer - dt) + } + + // Move toward target + if (h.targetX !== null && h.targetY !== null) { + const dx = h.targetX - t.x + const dy = h.targetY - t.y + const dist = Math.sqrt(dx * dx + dy * dy) + if (dist <= STOP_DIST) { + t.x = h.targetX; t.y = h.targetY + h.targetX = null; h.targetY = null + } else { + const step = Math.min(h.speed * dt, dist) + t.x += (dx / dist) * step + t.y += (dy / dist) * step + } + } + + // Bob animation on container + h.bobTime += dt + if (r?.container) { + r.container.x = t.x + r.container.y = t.y + Math.sin(h.bobTime * 2.5) * 2 + } + + // XP level-up check + const threshold = XP_PER_LEVEL[Math.min(h.level, XP_PER_LEVEL.length - 1)] + if (h.xp >= threshold && h.level < XP_PER_LEVEL.length) { + h.level++ + } + } +} + +export function heroAddXp(entities: EntityManager, amount: number): void { + for (const entity of entities.withTag('hero')) { + const h = entity.hero as HeroComp + if (h) h.xp += amount + } +} + +export function getHeroComp(entities: EntityManager): HeroComp | null { + const heroes = entities.withTag('hero') + if (heroes.length === 0) return null + return heroes[0].hero as HeroComp +} diff --git a/src/game/systems/LichKingSystem.ts b/src/game/systems/LichKingSystem.ts new file mode 100644 index 0000000..f31299e --- /dev/null +++ b/src/game/systems/LichKingSystem.ts @@ -0,0 +1,169 @@ +import { Graphics } from 'pixi.js' +import type { EntityManager } from '@/game/core/EntityManager' +import type { HealthComp, MovementComp, RenderComp, TransformComp } from '@/game/components' +import type { LichKingComp } from '@/game/entities/enemies/lich_king' +import { createWraith } from '@/game/entities/enemies/wraith' +import { createNecromancer } from '@/game/entities/enemies/necromancer' +import { eventBus } from '@/game/core/EventBus' +import { getWorldContainer } from '@/game/rendering/WorldContext' + +const PHASE2_HP = 0.6 // 60% → phase 2 +const PHASE3_HP = 0.3 // 30% → phase 3 + +export function lichKingSystem( + entities: EntityManager, + path: { x: number; y: number }[], + dt: number, +): void { + for (const entity of entities.withTag('lich_king')) { + if (entity.tags.has('dead')) continue + + const hp = entity.health as HealthComp + const lk = entity.lichKingComp as LichKingComp + const mv = entity.movement as MovementComp + const r = entity.render as RenderComp + if (!hp || !lk || !mv) continue + + const pct = hp.current / hp.max + + // Phase transitions + if (lk.phase === 1 && pct <= PHASE2_HP) { + _enterPhase2(entity, lk, mv, r) + } else if (lk.phase === 2 && pct <= PHASE3_HP) { + _enterPhase3(entity, lk, mv, r) + } + + lk.summonTimer -= dt + + if (lk.phase === 1) { + // Phase 1: summon 2 wraiths + if (lk.summonTimer <= 0) { + lk.summonTimer = lk.summonCooldown + _summonWraiths(entities, path, 2) + } + } else if (lk.phase === 2) { + // Phase 2: summon 1 necromancer + 1 wraith + if (lk.summonTimer <= 0) { + lk.summonTimer = lk.summonCooldown + _summonNecromancer(entities, path) + _summonWraiths(entities, path, 1) + } + } else { + // Phase 3: nexus pulse damage + lk.pulseTimer -= dt + if (lk.pulseTimer <= 0) { + lk.pulseTimer = lk.pulseCooldown + eventBus.emit('lich:nexus_damage', { amount: 1 }) + _spawnPulse(entity) + } + // Still summons in phase 3 + if (lk.summonTimer <= 0) { + lk.summonTimer = lk.summonCooldown + _summonWraiths(entities, path, 2) + } + } + } +} + +function _enterPhase2( + entity: ReturnType, + lk: LichKingComp, + mv: MovementComp, + r: RenderComp, +): void { + lk.phase = 2 + lk.summonCooldown = 5 + lk.summonTimer = 1 + mv.speed = 42 + if (r?.container) { + r.container.tint = 0xdd88ff + setTimeout(() => { if (r.container && !r.container.destroyed) r.container.tint = 0xffffff }, 300) + } + eventBus.emit('lich:phase_change', { entity, phase: 2 }) + _spawnPhaseShockwave(entity) +} + +function _enterPhase3( + entity: ReturnType, + lk: LichKingComp, + mv: MovementComp, + r: RenderComp, +): void { + lk.phase = 3 + lk.summonCooldown = 4 + lk.summonTimer = 1 + lk.pulseTimer = 2 + mv.speed = 55 + if (r?.container) { + r.container.tint = 0xff4488 + setTimeout(() => { if (r.container && !r.container.destroyed) r.container.tint = 0xffffff }, 500) + } + eventBus.emit('lich:phase_change', { entity, phase: 3 }) + _spawnPhaseShockwave(entity) + eventBus.emit('camera:shake', { intensity: 10, duration: 0.6 }) +} + +function _summonWraiths(entities: EntityManager, path: { x: number; y: number }[], count: number): void { + for (let i = 0; i < count; i++) { + const e = entities.create() + const sp = path[0] + const jitter = (Math.random() - 0.5) * 12 + createWraith(e, path, sp.x + jitter, sp.y + jitter) + } +} + +function _summonNecromancer(entities: EntityManager, path: { x: number; y: number }[]): void { + const e = entities.create() + const sp = path[0] + createNecromancer(e, path, sp.x, sp.y) +} + +function _spawnPhaseShockwave(entity: ReturnType): void { + const t = entity.transform as TransformComp + if (!t) return + const gfx = new Graphics() + gfx.x = t.x + gfx.y = t.y + gfx.zIndex = 200 + getWorldContainer().addChild(gfx) + + let radius = 0 + let alpha = 0.9 + const expand = () => { + if (gfx.destroyed) return + radius += 8 + alpha -= 0.045 + if (alpha <= 0) { gfx.destroy(); return } + gfx.clear() + gfx.circle(0, -30, radius) + gfx.stroke({ color: 0xcc00ff, width: 3, alpha }) + requestAnimationFrame(expand) + } + requestAnimationFrame(expand) +} + +function _spawnPulse(entity: ReturnType): void { + const t = entity.transform as TransformComp + if (!t) return + const gfx = new Graphics() + gfx.x = t.x + gfx.y = t.y + gfx.zIndex = 200 + getWorldContainer().addChild(gfx) + + let radius = 0 + let alpha = 0.8 + const expand = () => { + if (gfx.destroyed) return + radius += 14 + alpha -= 0.06 + if (alpha <= 0) { gfx.destroy(); return } + gfx.clear() + gfx.circle(0, -30, radius) + gfx.fill({ color: 0x440000, alpha: alpha * 0.3 }) + gfx.circle(0, -30, radius) + gfx.stroke({ color: 0xff0044, width: 2.5, alpha }) + requestAnimationFrame(expand) + } + requestAnimationFrame(expand) +} diff --git a/src/game/systems/MovementSystem.ts b/src/game/systems/MovementSystem.ts new file mode 100644 index 0000000..272a490 --- /dev/null +++ b/src/game/systems/MovementSystem.ts @@ -0,0 +1,49 @@ +import type { EntityManager } from '@/game/core/EntityManager' +import type { TransformComp, MovementComp } from '@/game/components' +import type { StatusEffectsComp } from '@/game/components/StatusEffect' +import { getSlowMultiplier } from '@/game/components/StatusEffect' +import { eventBus } from '@/game/core/EventBus' + +export function movementSystem(entities: EntityManager, dt: number): void { + for (const entity of entities.withTag('enemy')) { + if (entity.tags.has('dead')) continue + const t = entity.transform as TransformComp + const m = entity.movement as MovementComp + if (!t || !m || m.path.length === 0) continue + + const se = entity.statusEffects as StatusEffectsComp | undefined + const slowMult = se ? getSlowMultiplier(se) : 1 + + let remaining = m.speed * slowMult * dt + + while (remaining > 0 && m.pathIndex < m.path.length - 1) { + const target = m.path[m.pathIndex + 1] + const dx = target.x - t.x + const dy = target.y - t.y + const dist = Math.sqrt(dx * dx + dy * dy) + + if (dist === 0) { m.pathIndex++; continue } + + if (remaining >= dist) { + t.x = target.x + t.y = target.y + m.pathIndex++ + remaining -= dist + m.distanceTravelled += dist + } else { + const nx = dx / dist + const ny = dy / dist + t.x += nx * remaining + t.y += ny * remaining + t.rotation = Math.atan2(dy, dx) + m.distanceTravelled += remaining + remaining = 0 + } + } + + if (m.pathIndex >= m.path.length - 1) { + eventBus.emit('enemy:reached_end', { id: entity.id, damage: 1 }) + entity.tags.add('dead') + } + } +} diff --git a/src/game/systems/NecromancerSystem.ts b/src/game/systems/NecromancerSystem.ts new file mode 100644 index 0000000..d78cc66 --- /dev/null +++ b/src/game/systems/NecromancerSystem.ts @@ -0,0 +1,57 @@ +import { Graphics } from 'pixi.js' +import type { EntityManager } from '@/game/core/EntityManager' +import type { TransformComp, HealthComp, RenderComp } from '@/game/components' +import type { NecroComp } from '@/game/entities/enemies/necromancer' +import { refreshHp } from '@/game/entities/enemies/updateHpBar' +import { getWorldContainer } from '@/game/rendering/WorldContext' + +export function necromancerSystem(entities: EntityManager, dt: number): void { + for (const entity of entities.withTag('necromancer')) { + if (entity.tags.has('dead')) continue + const comp = entity.necroComp as NecroComp + if (!comp) continue + comp.healTimer -= dt + if (comp.healTimer > 0) continue + comp.healTimer = comp.healCooldown + + const t = entity.transform as TransformComp + if (!t) continue + + let healed = false + for (const other of entities.withTag('enemy')) { + if (other.id === entity.id || other.tags.has('dead')) continue + const ot = other.transform as TransformComp + if (!ot) continue + const dx = ot.x - t.x + const dy = ot.y - t.y + if (dx * dx + dy * dy > comp.healRadius * comp.healRadius) continue + const hp = other.health as HealthComp + if (!hp || hp.current >= hp.max) continue + hp.current = Math.min(hp.max, hp.current + comp.healAmount) + refreshHp(other) + healed = true + } + + if (healed) _spawnHealPulse(t.x, t.y, comp.healRadius) + } +} + +function _spawnHealPulse(x: number, y: number, r: number): void { + let world = null + try { world = getWorldContainer() } catch { return } + if (!world) return + const gfx = new Graphics() + gfx.circle(x, y, r) + gfx.stroke({ color: 0x9900cc, width: 3, alpha: 0.8 }) + gfx.circle(x, y, r * 0.5) + gfx.fill({ color: 0x660099, alpha: 0.2 }) + world.addChild(gfx) + let life = 0.5 + const tick = () => { + life -= 1 / 60 + gfx.alpha = Math.max(0, life / 0.5) + gfx.scale.set(1 + (0.5 - life) * 0.3) + if (life <= 0) gfx.destroy(); else requestAnimationFrame(tick) + } + requestAnimationFrame(tick) +} diff --git a/src/game/systems/ProjectileSystem.ts b/src/game/systems/ProjectileSystem.ts new file mode 100644 index 0000000..d70c3ff --- /dev/null +++ b/src/game/systems/ProjectileSystem.ts @@ -0,0 +1,225 @@ +import { Graphics, Text, TextStyle } from 'pixi.js' +import type { EntityManager, Entity } from '@/game/core/EntityManager' +import type { TransformComp, ProjectileComp, HealthComp, RenderComp, LootComp } from '@/game/components' +import type { StatusEffectsComp } from '@/game/components/StatusEffect' +import { applyStatus } from '@/game/components/StatusEffect' +import { resolveDamage } from '@/game/combat/DamageResolver' +import { eventBus } from '@/game/core/EventBus' +import { getWorldContainer } from '@/game/rendering/WorldContext' +import { refreshHp } from '@/game/entities/enemies/updateHpBar' +import { PYRO_AOE_RADIUS } from '@/game/entities/towers/pyromancer' + +const CHAIN_RANGE = 110 +const CHAIN_DMG_MULT = 0.6 + +export function projectileSystem(entities: EntityManager, dt: number): void { + for (const proj of entities.withTag('projectile')) { + const t = proj.transform as TransformComp + const p = proj.projectile as ProjectileComp + const r = proj.render as RenderComp + if (!t || !p) continue + + const target = entities.get(p.targetId) + if (!target || target.tags.has('dead')) { + r?.container?.destroy() + entities.destroy(proj.id) + continue + } + + const te = target.transform as TransformComp + if (!te) continue + + const dx = te.x - t.x + const dy = te.y - t.y + const d2 = dx * dx + dy * dy + const step = p.speed * dt + + if (d2 <= (step + p.hitRadius) * (step + p.hitRadius)) { + const health = target.health as HealthComp + if (health) { + const dmg = resolveDamage(p.damage, p.damageType, health) + health.current -= dmg + refreshHp(target) + spawnDamageNumber(te.x, te.y, dmg) + if (dmg > 0) flashHit(target) + + if (health.current <= 0) killEnemy(target) + } + + applyProjectileEffect(entities, p, target, te) + + r?.container?.destroy() + entities.destroy(proj.id) + } else { + const d = Math.sqrt(d2) + t.x += (dx / d) * step + t.y += (dy / d) * step + t.rotation = Math.atan2(dy, dx) + if (r?.container) { + r.container.x = t.x + r.container.y = t.y + r.container.rotation = t.rotation + } + } + } +} + +function applyProjectileEffect( + entities: EntityManager, + p: ProjectileComp, + primary: ReturnType, + primaryPos: TransformComp, +): void { + if (!primary) return + + if (p.projectileType === 'fireball') { + // AoE splash + const se = primary.statusEffects as StatusEffectsComp | undefined + if (se) applyStatus(se, { type: 'burn', duration: 3, strength: 4 }) + + for (const enemy of entities.withTag('enemy')) { + if (enemy.id === primary.id || enemy.tags.has('dead')) continue + const et = enemy.transform as TransformComp + if (!et) continue + const dx = et.x - primaryPos.x + const dy = et.y - primaryPos.y + if (dx * dx + dy * dy > PYRO_AOE_RADIUS * PYRO_AOE_RADIUS) continue + + const h = enemy.health as HealthComp + if (h) { + const aoe = resolveDamage(Math.floor(p.damage * 0.6), p.damageType, h) + h.current -= aoe + refreshHp(enemy) + spawnDamageNumber(et.x, et.y, aoe) + if (h.current <= 0) killEnemy(enemy) + } + const ese = enemy.statusEffects as StatusEffectsComp | undefined + if (ese) applyStatus(ese, { type: 'burn', duration: 3, strength: 4 }) + } + flashCircle(primaryPos.x, primaryPos.y, PYRO_AOE_RADIUS) + + } else if (p.projectileType === 'icicle') { + const se = primary.statusEffects as StatusEffectsComp | undefined + if (se) applyStatus(se, { type: 'slow', duration: 2.5, strength: 0.5 }) + + } else if (p.projectileType === 'lightning') { + // chain to 2 nearest enemies + const candidates = entities.withTag('enemy') + .filter((e) => !e.tags.has('dead') && e.id !== primary.id) + candidates.sort((a, b) => { + const at = a.transform as TransformComp + const bt = b.transform as TransformComp + const da = (at.x - primaryPos.x) ** 2 + (at.y - primaryPos.y) ** 2 + const db = (bt.x - primaryPos.x) ** 2 + (bt.y - primaryPos.y) ** 2 + return da - db + }) + + for (const ch of candidates.slice(0, 2)) { + const ct = ch.transform as TransformComp + if (!ct) continue + const dd = (ct.x - primaryPos.x) ** 2 + (ct.y - primaryPos.y) ** 2 + if (dd > CHAIN_RANGE * CHAIN_RANGE) continue + + const ch2 = ch.health as HealthComp + if (ch2) { + const chDmg = resolveDamage(Math.floor(p.damage * CHAIN_DMG_MULT), p.damageType, ch2) + ch2.current -= chDmg + refreshHp(ch) + spawnDamageNumber(ct.x, ct.y, chDmg) + if (ch2.current <= 0) killEnemy(ch) + } + flashArc(primaryPos.x, primaryPos.y, ct.x, ct.y) + } + } +} + +function killEnemy(entity: NonNullable>): void { + entity.tags.add('dead') + const loot = entity.loot as LootComp | undefined + eventBus.emit('enemy:died', { + id: entity.id, + gold: loot?.gold ?? 0, + essence: loot?.essence ?? 0, + }) +} + +function spawnDamageNumber(x: number, y: number, dmg: number): void { + let world: ReturnType | null = null + try { world = getWorldContainer() } catch { return } + const text = new Text({ + text: String(dmg), + style: new TextStyle({ fill: 0xffdd44, fontSize: 14, fontFamily: 'monospace', fontWeight: 'bold' }), + }) + text.x = x + (Math.random() - 0.5) * 20 + text.y = y - 20 + text.anchor.set(0.5) + world.addChild(text) + + let vy = -60; let life = 0.8 + const tick = () => { + life -= 1 / 60; vy += 120 / 60; text.y += vy / 60 + text.alpha = Math.max(0, life / 0.8) + if (life <= 0) text.destroy(); else requestAnimationFrame(tick) + } + requestAnimationFrame(tick) +} + +function flashCircle(x: number, y: number, r: number): void { + let world: ReturnType | null = null + try { world = getWorldContainer() } catch { return } + const gfx = new Graphics() + gfx.circle(x, y, r) + gfx.fill({ color: 0xff6600, alpha: 0.35 }) + gfx.circle(x, y, r) + gfx.stroke({ color: 0xffaa00, width: 2, alpha: 0.7 }) + world.addChild(gfx) + let life = 0.3 + const tick = () => { + life -= 1 / 60 + gfx.alpha = Math.max(0, life / 0.3) + if (life <= 0) gfx.destroy(); else requestAnimationFrame(tick) + } + requestAnimationFrame(tick) +} + +function flashArc(x1: number, y1: number, x2: number, y2: number): void { + let world: ReturnType | null = null + try { world = getWorldContainer() } catch { return } + const gfx = new Graphics() + // zigzag lightning arc + const mx = (x1 + x2) / 2 + (Math.random() - 0.5) * 20 + const my = (y1 + y2) / 2 + (Math.random() - 0.5) * 20 + gfx.moveTo(x1, y1) + gfx.lineTo(mx, my) + gfx.lineTo(x2, y2) + gfx.stroke({ color: 0xffffff, width: 2, alpha: 0.9 }) + gfx.moveTo(x1, y1) + gfx.lineTo(mx, my) + gfx.lineTo(x2, y2) + gfx.stroke({ color: 0xaaaaff, width: 4, alpha: 0.4 }) + world.addChild(gfx) + let life = 0.2 + const tick = () => { + life -= 1 / 60 + gfx.alpha = Math.max(0, life / 0.2) + if (life <= 0) gfx.destroy(); else requestAnimationFrame(tick) + } + requestAnimationFrame(tick) +} + +function flashHit(entity: Entity): void { + const r = entity.render as RenderComp + if (!r?.container || r.container.destroyed) return + const flash = new Graphics() + flash.rect(-24, -24, 48, 48) + flash.fill({ color: 0xffffff, alpha: 0.6 }) + r.container.addChild(flash) + let life = 0.1 + const tick = () => { + life -= 1 / 60 + flash.alpha = Math.max(0, life / 0.1) + if (life <= 0) { if (!flash.destroyed) flash.destroy() } + else requestAnimationFrame(tick) + } + requestAnimationFrame(tick) +} diff --git a/src/game/systems/RenderSystem.ts b/src/game/systems/RenderSystem.ts new file mode 100644 index 0000000..c2653e7 --- /dev/null +++ b/src/game/systems/RenderSystem.ts @@ -0,0 +1,27 @@ +import type { EntityManager } from '@/game/core/EntityManager' +import type { TransformComp, RenderComp } from '@/game/components' +import type { StatusEffectsComp } from '@/game/components/StatusEffect' + +export function renderSystem(entities: EntityManager): void { + for (const entity of entities.withTag('unit')) { + const t = entity.transform as TransformComp + const r = entity.render as RenderComp + if (!t || !r?.container) continue + + r.container.x = t.x + r.container.y = t.y + r.container.zIndex = Math.floor(t.y) + + // Status effect tinting (enemies only) + if (entity.tags.has('enemy')) { + const se = entity.statusEffects as StatusEffectsComp | undefined + if (se) { + const hasSlow = se.effects.some((e) => e.type === 'slow' || e.type === 'freeze') + const hasBurn = se.effects.some((e) => e.type === 'burn') + if (hasSlow) r.container.tint = 0x88ccff + else if (hasBurn) r.container.tint = 0xff8844 + else r.container.tint = 0xffffff + } + } + } +} diff --git a/src/game/systems/StatusEffectSystem.ts b/src/game/systems/StatusEffectSystem.ts new file mode 100644 index 0000000..7bfec17 --- /dev/null +++ b/src/game/systems/StatusEffectSystem.ts @@ -0,0 +1,50 @@ +import type { EntityManager } from '@/game/core/EntityManager' +import type { HealthComp } from '@/game/components' +import type { StatusEffectsComp } from '@/game/components/StatusEffect' +import { eventBus } from '@/game/core/EventBus' +import type { LootComp } from '@/game/components' +import { refreshHp } from '@/game/entities/enemies/updateHpBar' + +const BURN_TICK = 0.5 + +export function statusEffectSystem(entities: EntityManager, dt: number): void { + for (const entity of entities.withTag('enemy')) { + if (entity.tags.has('dead')) continue + const hp = entity.health as HealthComp | undefined + const se = entity.statusEffects as StatusEffectsComp | undefined + + // HP regen (troll, etc.) + if (hp && hp.regenRate && hp.regenRate > 0) { + hp.current = Math.min(hp.max, hp.current + hp.regenRate * dt) + refreshHp(entity) + } + + if (!se) continue + let burnDmg = 0 + + for (let i = se.effects.length - 1; i >= 0; i--) { + const eff = se.effects[i] + eff.duration -= dt + + if (eff.type === 'burn') { + eff.tickTimer -= dt + if (eff.tickTimer <= 0) { + burnDmg += Math.round(eff.strength * BURN_TICK) + eff.tickTimer = BURN_TICK + } + } + + if (eff.duration <= 0) se.effects.splice(i, 1) + } + + if (hp && burnDmg > 0) { + hp.current -= burnDmg + refreshHp(entity) + if (hp.current <= 0) { + entity.tags.add('dead') + const loot = entity.loot as LootComp | undefined + eventBus.emit('enemy:died', { id: entity.id, gold: loot?.gold ?? 0, essence: loot?.essence ?? 0 }) + } + } + } +} diff --git a/src/game/systems/TargetingSystem.ts b/src/game/systems/TargetingSystem.ts new file mode 100644 index 0000000..3b7a4eb --- /dev/null +++ b/src/game/systems/TargetingSystem.ts @@ -0,0 +1,56 @@ +import type { EntityManager } from '@/game/core/EntityManager' +import type { TransformComp, TargetingComp, MovementComp, HealthComp } from '@/game/components' +import { dist2 } from '@/lib/math' + +export function targetingSystem(entities: EntityManager): void { + const enemies = entities.withTag('enemy').filter((e) => !e.tags.has('dead')) + + for (const tower of entities.withTag('tower')) { + const t = tower.transform as TransformComp + const tgt = tower.targeting as TargetingComp + if (!t || !tgt) continue + + // validate current target + if (tgt.targetId !== null) { + const cur = entities.get(tgt.targetId) + if (!cur || cur.tags.has('dead')) { + tgt.targetId = null + } else { + const et = cur.transform as TransformComp + if (dist2(t.x, t.y, et.x, et.y) > tgt.range * tgt.range) { + tgt.targetId = null + } + } + } + + if (tgt.targetId !== null) continue + + let best = null + let bestVal = Infinity + + for (const enemy of enemies) { + const et = enemy.transform as TransformComp + if (!et) continue + const d2 = dist2(t.x, t.y, et.x, et.y) + if (d2 > tgt.range * tgt.range) continue + + let val: number + const mov = enemy.movement as MovementComp | undefined + const hp = enemy.health as HealthComp | undefined + + if (tgt.mode === 'first') { + val = -(mov?.distanceTravelled ?? 0) + } else if (tgt.mode === 'last') { + val = mov?.distanceTravelled ?? 0 + } else if (tgt.mode === 'strongest') { + val = -(hp?.current ?? 0) + } else { + val = d2 + } + + if (val < bestVal) { bestVal = val; best = enemy } + } + + tgt.targetId = best?.id ?? null + } +} diff --git a/src/game/systems/WaveSystem.ts b/src/game/systems/WaveSystem.ts new file mode 100644 index 0000000..743cd37 --- /dev/null +++ b/src/game/systems/WaveSystem.ts @@ -0,0 +1,119 @@ +import type { EntityManager } from '@/game/core/EntityManager' +import type { GridMap } from '@/game/map/GridMap' +import type { HealthComp, MovementComp } from '@/game/components' +import { createGoblin } from '@/game/entities/enemies/goblin' +import { createOrc } from '@/game/entities/enemies/orc' +import { createWarg } from '@/game/entities/enemies/warg' +import { createWraith } from '@/game/entities/enemies/wraith' +import { createTroll } from '@/game/entities/enemies/troll' +import { createGolem } from '@/game/entities/enemies/golem' +import { createNecromancer } from '@/game/entities/enemies/necromancer' +import { createDragon } from '@/game/entities/enemies/dragon' +import { createLichKing } from '@/game/entities/enemies/lich_king' +import { eventBus } from '@/game/core/EventBus' +import { getWaveDef, type EnemyType } from '@/data/waves' +import { useSettingsStore } from '@/state/settingsStore' +import { useGameStore } from '@/state/gameStore' + +export interface WaveState { + active: boolean + waveIndex: number + spawnQueue: EnemyType[] + spawnTimer: number + spawnInterval: number + aliveCount: number + countdown: number +} + +const BETWEEN_WAVE_TIME = 10 + +export function createWaveState(): WaveState { + return { + active: false, + waveIndex: 0, + spawnQueue: [], + spawnTimer: 0, + spawnInterval: 1.2, + aliveCount: 0, + countdown: 0, + } +} + +export function startWave(state: WaveState, waveIndex: number): void { + if (state.active) return + const levelId = useGameStore.getState().currentLevelId + const def = getWaveDef(waveIndex, levelId) + state.active = true + state.waveIndex = waveIndex + state.spawnQueue = [...def.enemies] + state.spawnTimer = 0 + state.spawnInterval = def.interval + state.aliveCount = state.spawnQueue.length + state.countdown = 0 + eventBus.emit('wave:start', { waveIndex }) +} + +export function waveSystem( + state: WaveState, + entities: EntityManager, + _map: GridMap, + path: { x: number; y: number }[], + dt: number, +): void { + if (!state.active) return + + const alive = entities.withTag('enemy').filter((e) => !e.tags.has('dead')) + state.aliveCount = alive.length + state.spawnQueue.length + + if (state.spawnQueue.length > 0) { + state.spawnTimer -= dt + if (state.spawnTimer <= 0) { + const type = state.spawnQueue.shift()! + const entity = entities.create() + const spawn = path[0] + const sx = spawn.x + (Math.random() - 0.5) * 8 + const sy = spawn.y + (Math.random() - 0.5) * 8 + spawnEnemy(type, entity, path, sx, sy) + applyDifficulty(entity) + state.spawnTimer = state.spawnInterval + } + } + + if (state.spawnQueue.length === 0 && alive.length === 0) { + state.active = false + state.countdown = BETWEEN_WAVE_TIME + eventBus.emit('wave:end', { waveIndex: state.waveIndex }) + } +} + +function applyDifficulty(entity: ReturnType): void { + const diff = useSettingsStore.getState().difficulty + if (diff === 'normal') return + const hpMult = diff === 'heroic' ? 1.35 : 1.75 + const speedMult = diff === 'heroic' ? 1.1 : 1.25 + const hp = entity.health as HealthComp | undefined + if (hp) { hp.current = Math.round(hp.current * hpMult); hp.max = Math.round(hp.max * hpMult) } + const mv = entity.movement as MovementComp | undefined + if (mv) mv.speed = Math.round(mv.speed * speedMult) +} + +function spawnEnemy( + type: EnemyType, + entity: ReturnType, + path: { x: number; y: number }[], + sx: number, + sy: number, +): void { + const flyPath = [path[0], path[path.length - 1]] + switch (type) { + case 'orc': createOrc(entity, path, sx, sy); break + case 'warg': createWarg(entity, path, sx, sy); break + case 'wraith': createWraith(entity, path, sx, sy); break + case 'troll': createTroll(entity, path, sx, sy); break + case 'golem': createGolem(entity, path, sx, sy); break + case 'necromancer': createNecromancer(entity, path, sx, sy); break + case 'dragon': createDragon(entity, flyPath, sx, sy - 30); break + case 'lich_king': createLichKing(entity, path, sx, sy); break + default: createGoblin(entity, path, sx, sy); break + } +} diff --git a/src/lib/math.ts b/src/lib/math.ts new file mode 100644 index 0000000..948374f --- /dev/null +++ b/src/lib/math.ts @@ -0,0 +1,16 @@ +export const lerp = (a: number, b: number, t: number) => a + (b - a) * t + +export const clamp = (v: number, min: number, max: number) => + Math.min(Math.max(v, min), max) + +export const dist = (ax: number, ay: number, bx: number, by: number) => + Math.sqrt((bx - ax) ** 2 + (by - ay) ** 2) + +export const dist2 = (ax: number, ay: number, bx: number, by: number) => + (bx - ax) ** 2 + (by - ay) ** 2 + +export const angleTo = (ax: number, ay: number, bx: number, by: number) => + Math.atan2(by - ay, bx - ax) + +export const toRad = (deg: number) => (deg * Math.PI) / 180 +export const toDeg = (rad: number) => (rad * 180) / Math.PI diff --git a/src/lib/rng.ts b/src/lib/rng.ts new file mode 100644 index 0000000..678873f --- /dev/null +++ b/src/lib/rng.ts @@ -0,0 +1,16 @@ +export function mulberry32(seed: number) { + return function () { + seed |= 0 + seed = (seed + 0x6d2b79f5) | 0 + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +let _rand = mulberry32(Date.now()) + +export const rand = () => _rand() +export const randInt = (min: number, max: number) => Math.floor(rand() * (max - min + 1)) + min +export const randOf = (arr: T[]): T => arr[randInt(0, arr.length - 1)] +export const setSeed = (seed: number) => { _rand = mulberry32(seed) } diff --git a/src/lib/save.ts b/src/lib/save.ts new file mode 100644 index 0000000..c53ddac --- /dev/null +++ b/src/lib/save.ts @@ -0,0 +1,53 @@ +const SAVE_KEY = 'arcanum_save' +const SAVE_VERSION = 2 + +export interface SaveData { + version: number + meta: { + essence: number + unlockedRunes: string[] + completedLevels: string[] + } + settings: { + masterVolume: number + sfxVolume: number + musicVolume: number + difficulty?: string + } +} + +const DEFAULT_SAVE: SaveData = { + version: SAVE_VERSION, + meta: { + essence: 0, + unlockedRunes: ['pierce', 'burn'], + completedLevels: [], + }, + settings: { + masterVolume: 0.8, + sfxVolume: 0.7, + musicVolume: 0.5, + difficulty: 'normal', + }, +} + +export function loadSave(): SaveData { + try { + const raw = localStorage.getItem(SAVE_KEY) + if (!raw) return { ...DEFAULT_SAVE } + const parsed = JSON.parse(raw) as SaveData + // Migrate older saves + if (!parsed.version || parsed.version < SAVE_VERSION) return { ...DEFAULT_SAVE } + return parsed + } catch { + return { ...DEFAULT_SAVE } + } +} + +export function writeSave(data: SaveData): void { + localStorage.setItem(SAVE_KEY, JSON.stringify({ ...data, version: SAVE_VERSION })) +} + +export function clearSave(): void { + localStorage.removeItem(SAVE_KEY) +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..cbd46a8 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,15 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import '@/styles/globals.css' +import { App } from '@/App' +import { useMetaStore } from '@/state/metaStore' +import { useSettingsStore } from '@/state/settingsStore' + +useMetaStore.getState().load() +useSettingsStore.getState().load() + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/src/state/gameStore.ts b/src/state/gameStore.ts new file mode 100644 index 0000000..18243f9 --- /dev/null +++ b/src/state/gameStore.ts @@ -0,0 +1,85 @@ +import { create } from 'zustand' + +export type GameScreen = 'menu' | 'campaign' | 'game' | 'tome' | 'settings' | 'gameover' + +interface GameState { + screen: GameScreen + currentLevelId: string + gold: number + mana: number + nexusHp: number + nexusMaxHp: number + wave: number + maxWaves: number + phase: 'build' | 'combat' | 'paused' + won: boolean | null + selectedTowerTile: { x: number; y: number } | null + + setScreen: (s: GameScreen) => void + setCurrentLevelId: (id: string) => void + setGold: (g: number) => void + addGold: (g: number) => void + spendGold: (g: number) => boolean + setMana: (m: number) => void + addMana: (m: number) => void + spendMana: (m: number) => boolean + damageNexus: (dmg: number) => void + setWave: (w: number) => void + setPhase: (p: 'build' | 'combat' | 'paused') => void + setWon: (won: boolean) => void + setSelectedTowerTile: (tile: { x: number; y: number } | null) => void + resetGame: () => void +} + +export const useGameStore = create((set, get) => ({ + screen: 'menu', + currentLevelId: 'kings_road', + gold: 150, + mana: 100, + nexusHp: 20, + nexusMaxHp: 20, + wave: 0, + maxWaves: 20, + phase: 'build', + won: null, + selectedTowerTile: null, + + setScreen: (screen) => set({ screen }), + setCurrentLevelId: (currentLevelId) => set({ currentLevelId }), + setGold: (gold) => set({ gold }), + addGold: (amount) => set((s) => ({ gold: s.gold + amount })), + spendGold: (amount) => { + const { gold } = get() + if (gold < amount) return false + set({ gold: gold - amount }) + return true + }, + setMana: (mana) => set({ mana }), + addMana: (amount) => set((s) => ({ mana: Math.min(s.mana + amount, 200) })), + spendMana: (amount) => { + const { mana } = get() + if (mana < amount) return false + set({ mana: mana - amount }) + return true + }, + damageNexus: (dmg) => + set((s) => { + const nexusHp = Math.max(0, s.nexusHp - dmg) + return { nexusHp } + }), + setWave: (wave) => set({ wave }), + setPhase: (phase) => set({ phase }), + setWon: (won) => set({ won }), + setSelectedTowerTile: (tile) => set({ selectedTowerTile: tile }), + resetGame: () => + set({ + gold: 150, + mana: 100, + nexusHp: 20, + nexusMaxHp: 20, + wave: 0, + phase: 'build', + won: null, + selectedTowerTile: null, + }), +})) diff --git a/src/state/metaStore.ts b/src/state/metaStore.ts new file mode 100644 index 0000000..1e575a1 --- /dev/null +++ b/src/state/metaStore.ts @@ -0,0 +1,48 @@ +import { create } from 'zustand' +import { loadSave, writeSave } from '@/lib/save' + +interface MetaState { + essence: number + unlockedRunes: string[] + completedLevels: string[] + addEssence: (amount: number) => void + unlockRune: (id: string) => void + completeLevel: (id: string) => void + load: () => void +} + +export const useMetaStore = create((set, get) => ({ + essence: 0, + unlockedRunes: ['pierce', 'burn'], + completedLevels: [], + + addEssence: (amount) => { + set((s) => ({ essence: s.essence + amount })) + const s = get() + const save = loadSave() + writeSave({ ...save, meta: { ...save.meta, essence: s.essence } }) + }, + + unlockRune: (id) => { + set((s) => ({ unlockedRunes: [...new Set([...s.unlockedRunes, id])] })) + const s = get() + const save = loadSave() + writeSave({ ...save, meta: { ...save.meta, unlockedRunes: s.unlockedRunes } }) + }, + + completeLevel: (id) => { + set((s) => ({ completedLevels: [...new Set([...s.completedLevels, id])] })) + const s = get() + const save = loadSave() + writeSave({ ...save, meta: { ...save.meta, completedLevels: s.completedLevels } }) + }, + + load: () => { + const save = loadSave() + set({ + essence: save.meta.essence, + unlockedRunes: save.meta.unlockedRunes, + completedLevels: save.meta.completedLevels, + }) + }, +})) diff --git a/src/state/settingsStore.ts b/src/state/settingsStore.ts new file mode 100644 index 0000000..7cb7582 --- /dev/null +++ b/src/state/settingsStore.ts @@ -0,0 +1,53 @@ +import { create } from 'zustand' +import { loadSave, writeSave } from '@/lib/save' + +export type Difficulty = 'normal' | 'heroic' | 'nightmare' + +interface SettingsState { + masterVolume: number + sfxVolume: number + musicVolume: number + showFps: boolean + difficulty: Difficulty + setMasterVolume: (v: number) => void + setSfxVolume: (v: number) => void + setMusicVolume: (v: number) => void + setShowFps: (v: boolean) => void + setDifficulty: (d: Difficulty) => void + load: () => void +} + +export const useSettingsStore = create((set) => ({ + masterVolume: 0.8, + sfxVolume: 0.7, + musicVolume: 0.5, + showFps: false, + difficulty: 'normal', + + setMasterVolume: (masterVolume) => { + set({ masterVolume }) + const save = loadSave() + writeSave({ ...save, settings: { ...save.settings, masterVolume } }) + }, + setSfxVolume: (sfxVolume) => { + set({ sfxVolume }) + const save = loadSave() + writeSave({ ...save, settings: { ...save.settings, sfxVolume } }) + }, + setMusicVolume: (musicVolume) => { + set({ musicVolume }) + const save = loadSave() + writeSave({ ...save, settings: { ...save.settings, musicVolume } }) + }, + setShowFps: (showFps) => set({ showFps }), + setDifficulty: (difficulty) => { + set({ difficulty }) + const save = loadSave() + writeSave({ ...save, settings: { ...save.settings, difficulty } }) + }, + + load: () => { + const save = loadSave() + set({ ...save.settings }) + }, +})) diff --git a/src/styles/globals.css b/src/styles/globals.css new file mode 100644 index 0000000..7136fa4 --- /dev/null +++ b/src/styles/globals.css @@ -0,0 +1,345 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + *, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; + } + + html, body, #root { + width: 100%; + height: 100%; + overflow: hidden; + background: #0E1220; + color: #E9DCC0; + font-family: 'EB Garamond', serif; + } + + ::-webkit-scrollbar { width: 5px; height: 5px; } + ::-webkit-scrollbar-track { background: rgba(14,18,32,0.8); } + ::-webkit-scrollbar-thumb { + background: rgba(201,161,74,0.3); + border-radius: 3px; + } + ::-webkit-scrollbar-thumb:hover { background: rgba(201,161,74,0.55); } + + input[type=range] { + -webkit-appearance: none; + height: 4px; + border-radius: 2px; + background: rgba(201,161,74,0.25); + outline: none; + } + input[type=range]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: #C9A14A; + cursor: pointer; + border: 2px solid #0E1220; + box-shadow: 0 0 6px rgba(201,161,74,0.6); + } +} + +@layer components { + + /* ═══════════════════════════════════════ + BUTTONS + ═══════════════════════════════════════ */ + + .btn-rune { + @apply relative font-cinzel font-semibold tracking-widest uppercase px-6 py-3 + transition-all duration-300 cursor-pointer select-none; + color: #E9DCC0; + border: 1px solid rgba(201,161,74,0.5); + background-color: #1a2035; + clip-path: polygon(8px 0%, 100% 0%, calc(100% - 8px) 100%, 0% 100%); + } + .btn-rune::before { + content: ''; + @apply absolute inset-0 opacity-0 transition-opacity duration-300; + background: linear-gradient(135deg, rgba(201,161,74,0.08) 0%, transparent 60%); + } + .btn-rune:hover { + border-color: #C9A14A; + color: #C9A14A; + box-shadow: 0 0 20px 4px rgba(201,161,74,0.4), inset 0 0 12px rgba(201,161,74,0.08); + } + .btn-rune:hover::before { @apply opacity-100; } + .btn-rune:active { transform: scale(0.97); } + .btn-rune:disabled { + @apply opacity-30 cursor-not-allowed; + box-shadow: none; + } + + .btn-ghost { + @apply font-cinzel font-medium tracking-widest uppercase px-5 py-2.5 + transition-all duration-200 cursor-pointer select-none; + color: rgba(233,220,192,0.55); + border: 1px solid rgba(233,220,192,0.2); + } + .btn-ghost:hover { + border-color: rgba(233,220,192,0.5); + color: #E9DCC0; + } + + .btn-danger { + @apply relative font-cinzel font-semibold tracking-widest uppercase px-6 py-3 + transition-all duration-300 cursor-pointer select-none; + color: #fda4af; + border: 1px solid rgba(225,29,72,0.4); + background-color: #1a2035; + clip-path: polygon(8px 0%, 100% 0%, calc(100% - 8px) 100%, 0% 100%); + } + .btn-danger:hover { + border-color: rgba(251,113,133,0.8); + color: #fecdd3; + background-color: rgba(136,19,55,0.3); + box-shadow: 0 0 16px rgba(244,63,94,0.35); + } + + .btn-frost { + @apply relative font-cinzel font-semibold tracking-widest uppercase px-6 py-3 + transition-all duration-300 cursor-pointer select-none; + color: #6ECBD5; + border: 1px solid rgba(110,203,213,0.4); + background-color: #1a2035; + clip-path: polygon(8px 0%, 100% 0%, calc(100% - 8px) 100%, 0% 100%); + } + .btn-frost:hover { + border-color: #6ECBD5; + background-color: rgba(110,203,213,0.1); + box-shadow: 0 0 18px rgba(110,203,213,0.4); + } + + /* ═══════════════════════════════════════ + PANELS + ═══════════════════════════════════════ */ + + .panel-parchment { + @apply relative; + background-color: #1a2035; + border: 1px solid rgba(201,161,74,0.3); + background-image: + radial-gradient(ellipse at top left, rgba(201,161,74,0.06) 0%, transparent 55%), + radial-gradient(ellipse at bottom right, rgba(110,203,213,0.04) 0%, transparent 55%); + box-shadow: + 0 0 0 1px rgba(201,161,74,0.12), + 0 6px 40px rgba(0,0,0,0.65), + inset 0 1px 0 rgba(201,161,74,0.18); + } + + .panel-dark { + @apply relative; + background-color: #070b14; + border: 1px solid rgba(233,220,192,0.1); + box-shadow: + 0 0 0 1px rgba(255,255,255,0.04), + 0 8px 48px rgba(0,0,0,0.85), + inset 0 1px 0 rgba(255,255,255,0.04); + } + + .panel-frost { + @apply relative; + background-color: #0E1220; + border: 1px solid rgba(110,203,213,0.3); + background-image: + radial-gradient(ellipse at top, rgba(110,203,213,0.08) 0%, transparent 60%); + box-shadow: + 0 0 0 1px rgba(110,203,213,0.12), + 0 4px 32px rgba(0,0,0,0.6), + inset 0 1px 0 rgba(110,203,213,0.12); + } + + .panel-boss { + @apply relative; + background-color: #070b14; + border: 1px solid rgba(155,77,232,0.4); + background-image: + radial-gradient(ellipse at top, rgba(155,77,232,0.08) 0%, transparent 60%); + box-shadow: + 0 0 0 1px rgba(155,77,232,0.2), + 0 8px 48px rgba(0,0,0,0.85), + 0 0 60px rgba(155,77,232,0.1), + inset 0 1px 0 rgba(155,77,232,0.1); + } + + .hud-panel { + @apply relative backdrop-blur-sm; + background-color: rgba(14,18,32,0.8); + border: 1px solid rgba(201,161,74,0.2); + box-shadow: + 0 0 0 1px rgba(201,161,74,0.08), + 0 4px 24px rgba(0,0,0,0.7), + inset 0 1px 0 rgba(201,161,74,0.12); + } + + /* ═══════════════════════════════════════ + DIVIDERS + ═══════════════════════════════════════ */ + + .rune-divider { + @apply w-full h-px; + background: linear-gradient( + 90deg, + transparent 0%, + rgba(201,161,74,0.25) 15%, + rgba(201,161,74,0.65) 50%, + rgba(201,161,74,0.25) 85%, + transparent 100% + ); + } + + .rune-divider-frost { + @apply w-full h-px; + background: linear-gradient( + 90deg, + transparent 0%, + rgba(110,203,213,0.25) 15%, + rgba(110,203,213,0.65) 50%, + rgba(110,203,213,0.25) 85%, + transparent 100% + ); + } + + .rune-divider-rose { + @apply w-full h-px; + background: linear-gradient( + 90deg, + transparent 0%, + rgba(244,63,94,0.25) 15%, + rgba(244,63,94,0.65) 50%, + rgba(244,63,94,0.25) 85%, + transparent 100% + ); + } + + /* ═══════════════════════════════════════ + TYPOGRAPHY + ═══════════════════════════════════════ */ + + .text-title { + @apply font-cinzel font-bold tracking-widest; + color: #C9A14A; + text-shadow: 0 0 24px rgba(201,161,74,0.55), 0 2px 4px rgba(0,0,0,0.8); + } + + .text-subtitle { + @apply font-cinzel font-normal tracking-wider; + color: rgba(233,220,192,0.65); + } + + .text-glow-gold { + text-shadow: 0 0 12px rgba(201,161,74,0.9), 0 0 24px rgba(201,161,74,0.4); + } + .text-glow-frost { + text-shadow: 0 0 12px rgba(110,203,213,0.9), 0 0 24px rgba(110,203,213,0.4); + } + .text-glow-ember { + text-shadow: 0 0 12px rgba(232,112,42,0.9), 0 0 24px rgba(232,112,42,0.4); + } + .text-glow-rose { + text-shadow: 0 0 12px rgba(244,63,94,0.9), 0 0 24px rgba(244,63,94,0.4); + } + .text-glow-arcane { + text-shadow: 0 0 12px rgba(155,77,232,0.9), 0 0 24px rgba(155,77,232,0.4); + } + + .text-shimmer { + background: linear-gradient( + 90deg, + rgba(201,161,74,0.6) 0%, + rgba(240,192,96,1) 40%, + rgba(201,161,74,0.6) 60%, + rgba(240,192,96,1) 100% + ); + background-size: 200% auto; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + animation: shimmer 2.5s linear infinite; + } + + /* ═══════════════════════════════════════ + ORNAMENTS + ═══════════════════════════════════════ */ + + .ornament-line { + @apply flex items-center gap-3; + } + .ornament-line::before, .ornament-line::after { + content: ''; + @apply flex-1 h-px; + background: linear-gradient(90deg, transparent, rgba(201,161,74,0.45)); + } + .ornament-line::after { + background: linear-gradient(90deg, rgba(201,161,74,0.45), transparent); + } + + .corner-mark { + @apply absolute w-3 h-3; + border-color: rgba(201,161,74,0.6); + } + .corner-mark-tl { @apply top-0 left-0 border-t border-l; } + .corner-mark-tr { @apply top-0 right-0 border-t border-r; } + .corner-mark-bl { @apply bottom-0 left-0 border-b border-l; } + .corner-mark-br { @apply bottom-0 right-0 border-b border-r; } + + /* ═══════════════════════════════════════ + STAT CELLS + ═══════════════════════════════════════ */ + + .stat-cell { + @apply flex flex-col items-center gap-0.5 px-2 py-1.5; + } + .stat-cell-label { + @apply font-cinzel text-[9px] uppercase tracking-wider; + color: rgba(233,220,192,0.4); + } + .stat-cell-value { + @apply font-cinzel text-sm font-bold; + color: #E9DCC0; + } + + .toggle-switch { + @apply relative w-11 h-6 cursor-pointer transition-colors duration-200; + } + .toggle-switch input { @apply sr-only; } +} + +@layer utilities { + .glow-gold { box-shadow: 0 0 14px rgba(201,161,74,0.65); } + .glow-frost { box-shadow: 0 0 14px rgba(110,203,213,0.65); } + .glow-ember { box-shadow: 0 0 14px rgba(232,112,42,0.65); } + .glow-rose { box-shadow: 0 0 14px rgba(244,63,94,0.65); } + .glow-arcane { box-shadow: 0 0 14px rgba(155,77,232,0.65); } + + .glow-gold-lg { box-shadow: 0 0 28px rgba(201,161,74,0.5), 0 0 56px rgba(201,161,74,0.2); } + .glow-frost-lg { box-shadow: 0 0 28px rgba(110,203,213,0.5), 0 0 56px rgba(110,203,213,0.2); } + .glow-rose-lg { box-shadow: 0 0 28px rgba(244,63,94,0.5), 0 0 56px rgba(244,63,94,0.2); } + .glow-arcane-lg { box-shadow: 0 0 28px rgba(155,77,232,0.5), 0 0 56px rgba(155,77,232,0.2); } + + .bg-gradient-radial { + background: radial-gradient(var(--tw-gradient-stops)); + } + + .clip-rune { + clip-path: polygon(8px 0%, 100% 0%, calc(100% - 8px) 100%, 0% 100%); + } + .clip-diamond { + clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%); + } + .clip-hex { + clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%); + } + + .border-ornament { + border-width: 1px; + border-style: solid; + border-image: linear-gradient(135deg, rgba(201,161,74,0.6) 0%, rgba(201,161,74,0.15) 50%, rgba(201,161,74,0.6) 100%) 1; + } +} diff --git a/src/ui/hud/BossCutscene.tsx b/src/ui/hud/BossCutscene.tsx new file mode 100644 index 0000000..f7b1bbb --- /dev/null +++ b/src/ui/hud/BossCutscene.tsx @@ -0,0 +1,113 @@ +import { useState, useEffect } from 'react' +import { useGameStore } from '@/state/gameStore' + +export function BossCutscene() { + const currentLevelId = useGameStore((s) => s.currentLevelId) + const wave = useGameStore((s) => s.wave) + const [visible, setVisible] = useState(false) + const [dismissed, setDismissed] = useState(false) + + useEffect(() => { + if (currentLevelId === 'obsidian_keep' && wave === 0 && !dismissed) { + setVisible(true) + } + }, [currentLevelId, wave, dismissed]) + + if (!visible) return null + + const dismiss = () => { setVisible(false); setDismissed(true) } + + return ( +
+ {/* Atmospheric background */} +
+
+ + {/* Card */} +
e.stopPropagation()} + style={{ boxShadow: '0 0 60px rgba(155,77,232,0.15), inset 0 0 40px rgba(0,0,0,0.3)' }} + > + {/* Corner marks */} + + + + + + {/* Boss icon */} +
+
+ 💀 +
+
+
+ + {/* Title */} +
+

Финальное испытание

+

+ Обсидиановая Цитадель +

+

+ — Король-Лич ожидает — +

+
+ +
+ +
+ + {/* Lore */} +
+

+ В самом сердце Чёрных Земель восстал{' '} + Король-Лич — повелитель + смерти, некогда павший от руки Первых Архимагов. Теперь он ведёт бесконечные + орды нежити на последний оплот живых. +

+

+ Он проходит через{' '} + три фазы могущества. В последней — + наносит прямой урон нексусу.{' '} + Уничтожь его прежде, чем цитадель падёт. +

+
+ +
+ +
+ + {/* Rewards */} +
+
+ + +30 Эссенции +
+
+ + +150 Золота +
+
+ + + +

нажмите куда угодно, чтобы закрыть

+
+
+ ) +} diff --git a/src/ui/hud/GameHUD.tsx b/src/ui/hud/GameHUD.tsx new file mode 100644 index 0000000..37df478 --- /dev/null +++ b/src/ui/hud/GameHUD.tsx @@ -0,0 +1,231 @@ +import { useState, useEffect } from 'react' +import { useGameStore } from '@/state/gameStore' +import { getEngine } from '@/game/core/GameEngine' +import type { PlacementMode } from '@/game/LevelScene' +import { ARCHER_COST } from '@/game/entities/towers/archer' +import { PYROMANCER_COST } from '@/game/entities/towers/pyromancer' +import { CRYO_COST } from '@/game/entities/towers/cryomancer' +import { STORM_COST } from '@/game/entities/towers/stormcaller' +import { TowerInfoPanel } from './TowerInfoPanel' +import { SpellBar } from './SpellBar' +import { BossCutscene } from './BossCutscene' + +interface GameHUDProps { onExit: () => void } + +const TOWER_DEFS = [ + { mode: 'archer' as PlacementMode, icon: '🏹', name: 'Лучник', cost: ARCHER_COST, title: 'Лучники — дальний физический урон' }, + { mode: 'pyromancer' as PlacementMode, icon: '🔥', name: 'Пиромант', cost: PYROMANCER_COST, title: 'Пиромант — AoE огонь + поджог' }, + { mode: 'cryomancer' as PlacementMode, icon: '❄', name: 'Криомант', cost: CRYO_COST, title: 'Криомант — замедление + заморозка' }, + { mode: 'stormcaller' as PlacementMode, icon: '⚡', name: 'Шторм', cost: STORM_COST, title: 'Повелитель бурь — цепная молния' }, +] + +export function GameHUD({ onExit }: GameHUDProps) { + const { gold, mana, nexusHp, nexusMaxHp, wave, maxWaves, phase } = useGameStore() + const [placement, setPlacement] = useState('none') + const [countdown, setCountdown] = useState(0) + const [aliveCount, setAliveCount] = useState(0) + + useEffect(() => { + if (phase !== 'build') { setCountdown(0); return } + const id = setInterval(() => { + const wv = getEngine()?.getScene()?.getWaveState() + setCountdown(wv ? Math.ceil(wv.countdown) : 0) + }, 100) + return () => clearInterval(id) + }, [phase]) + + useEffect(() => { + if (phase !== 'combat') { setAliveCount(0); return } + const id = setInterval(() => { + setAliveCount(getEngine()?.getScene()?.getWaveState()?.aliveCount ?? 0) + }, 200) + return () => clearInterval(id) + }, [phase]) + + useEffect(() => { + const cancel = (e: MouseEvent) => { + if (e.button === 2) { setPlacement('none'); getEngine()?.getScene()?.setPlacementMode('none') } + } + window.addEventListener('mousedown', cancel) + return () => window.removeEventListener('mousedown', cancel) + }, []) + + const handleStartWave = () => getEngine()?.getScene()?.startWave() + + const selectTower = (mode: PlacementMode) => { + const next = placement === mode ? 'none' : mode + setPlacement(next) + getEngine()?.getScene()?.setPlacementMode(next) + } + + const nexusPct = nexusHp / nexusMaxHp + const nexusColor = nexusPct > 0.5 ? '#4ade80' : nexusPct > 0.25 ? '#f59e0b' : '#ef4444' + + return ( +
+ + + {/* ── TOP ROW ── */} +
+ + {/* Resources — left */} +
+ +
+ +
+ + {/* Wave + phase — center */} +
+
+ + + {phase === 'build' ? 'Постройка' : phase === 'combat' ? 'Сражение' : 'Пауза'} + +
+
+ {wave} + /{maxWaves} +
+ {phase === 'combat' && aliveCount > 0 && ( + {aliveCount} врагов + )} + {phase === 'build' && countdown > 0 && ( + авто {countdown}с + )} +
+ + {/* Nexus + exit — right */} +
+
+
+ Нексус + + {nexusHp}/{nexusMaxHp} + +
+
+
+
+
+ +
+
+ + {/* ── PLACEMENT HINT ── */} + {placement !== 'none' && ( +
+
+ + Кликните на тайл · ПКМ отмена + +
+
+ )} + + {/* ── BOTTOM ROW ── */} +
+ + {/* Tower shop */} +
+ + Башни + + {TOWER_DEFS.map((td) => ( + selectTower(td.mode)} + /> + ))} +
+ + {/* Spell bar */} +
+ +
+ + {/* Wave control + TowerInfo */} +
+ {phase === 'build' && ( + + )} + {phase === 'combat' && ( +
+ + Бой идёт +
+ )} + +
+
+
+ ) +} + +function TowerBtn({ mode, icon, name, cost, gold, active, onClick, title }: { + mode: PlacementMode; icon: string; name: string; cost: number + gold: number; active: boolean; onClick: () => void; title: string +}) { + const canAfford = gold >= cost + return ( + + ) +} + +function ResourceChip({ icon, value, label, color }: { + icon: string; value: number; label: string; color: string +}) { + return ( +
+ {icon} +
+ {label} + {value} +
+
+ ) +} + +function PhaseDot({ phase }: { phase: 'build' | 'combat' | 'paused' }) { + const color = phase === 'combat' ? 'bg-rose-500' : phase === 'build' ? 'bg-emerald-500' : 'bg-parchment/35' + return +} diff --git a/src/ui/hud/SpellBar.tsx b/src/ui/hud/SpellBar.tsx new file mode 100644 index 0000000..59c5047 --- /dev/null +++ b/src/ui/hud/SpellBar.tsx @@ -0,0 +1,130 @@ +import { useState, useEffect, useCallback } from 'react' +import { useGameStore } from '@/state/gameStore' +import { getEngine } from '@/game/core/GameEngine' +import { HERO_SPELLS } from '@/game/components/Hero' + +export function SpellBar() { + const mana = useGameStore((s) => s.mana) + const [timers, setTimers] = useState>({}) + const [spellMode, setSpellMode] = useState(null) + const [heroLevel, setHeroLevel] = useState(1) + + const sync = useCallback(() => { + const scene = getEngine()?.getScene() + if (!scene) return + const h = scene.getHeroComp() + if (h) { + const t: Record = {} + for (const s of h.spells) t[s.id] = s.timer + setTimers(t) + setHeroLevel(h.level) + } + setSpellMode(scene.getSpellMode()) + }, []) + + useEffect(() => { + const id = setInterval(sync, 100) + return () => clearInterval(id) + }, [sync]) + + useEffect(() => { + const keys: Record = { q: 'fireball', w: 'blizzard', e: 'blink', r: 'timewarp' } + const onKey = (e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement) return + const id = keys[e.key.toLowerCase()] + if (id) handleSpell(id) + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [mana, timers]) + + const handleSpell = (id: string) => { + const scene = getEngine()?.getScene() + if (!scene) return + const spell = HERO_SPELLS.find((s) => s.id === id) + if (!spell) return + const timer = timers[id] ?? 0 + if (timer > 0 || mana < spell.manaCost) return + if (spell.targetMode === 'global') { + scene.castSpell(id, 0, 0) + sync() + } else { + const next = scene.getSpellMode() === id ? null : id + scene.setSpellMode(next) + setSpellMode(next) + } + } + + return ( +
+
+ Архимаг + Ур.{heroLevel} +
+
+ {HERO_SPELLS.map((spell) => { + const timer = timers[spell.id] ?? 0 + const onCd = timer > 0 + const canAfford = mana >= spell.manaCost + const active = spellMode === spell.id + return ( + handleSpell(spell.id)} + /> + ) + })} +
+
+ ) +} + +function SpellBtn({ spell, timer, onCooldown, canAfford, active, onClick }: { + spell: typeof HERO_SPELLS[number] + timer: number; onCooldown: boolean; canAfford: boolean; active: boolean; onClick: () => void +}) { + const disabled = onCooldown || !canAfford + + return ( + + ) +} diff --git a/src/ui/hud/TowerInfoPanel.tsx b/src/ui/hud/TowerInfoPanel.tsx new file mode 100644 index 0000000..1e063ae --- /dev/null +++ b/src/ui/hud/TowerInfoPanel.tsx @@ -0,0 +1,116 @@ +import { useState, useEffect, useCallback } from 'react' +import { useGameStore } from '@/state/gameStore' +import { getEngine } from '@/game/core/GameEngine' +import type { TowerInfo } from '@/game/LevelScene' + +const TIER_LABELS = ['', 'I', 'II', 'III'] + +export function TowerInfoPanel() { + const selectedTile = useGameStore((s) => s.selectedTowerTile) + const gold = useGameStore((s) => s.gold) + const [info, setInfo] = useState(null) + + const refresh = useCallback(() => { + if (!selectedTile) { setInfo(null); return } + const next = getEngine()?.getScene()?.getTowerInfo(selectedTile.x, selectedTile.y) ?? null + setInfo(next) + }, [selectedTile]) + + useEffect(() => { refresh() }, [refresh]) + + if (!info) return null + + const canUpgrade = info.upgradeCost !== null && gold >= info.upgradeCost + const atMax = info.tier >= info.maxTier + + return ( +
+ + {/* Header */} +
+
+ {info.icon} +
+

{info.nameRu}

+
+ {Array.from({ length: info.maxTier }, (_, i) => ( + + ))} + + {TIER_LABELS[info.tier]} + +
+
+
+ +
+ + {/* Ley-line badge */} + {info.leyLineBuff && ( +
+ + Лей-линия ×1.2 +
+ )} + + {/* Stats */} +
+ + + +
+ + {/* Buttons */} +
+ + +
+
+ ) +} + +function StatCell({ label, value, border }: { label: string; value: number | string; border?: boolean }) { + return ( +
+ {label} + {value} +
+ ) +} diff --git a/src/ui/screens/CampaignMap.tsx b/src/ui/screens/CampaignMap.tsx new file mode 100644 index 0000000..291749d --- /dev/null +++ b/src/ui/screens/CampaignMap.tsx @@ -0,0 +1,218 @@ +import { useGameStore } from '@/state/gameStore' +import { useMetaStore } from '@/state/metaStore' + +interface Level { + id: string + name: string + nameRu: string + waves: number + difficulty: 'Новобранец' | 'Рыцарь' | 'Архимаг' | 'Босс' + unlockedBy: string | null + x: string + y: string + biomeColor: string + biomeGlow: string + icon: string +} + +const LEVELS: Level[] = [ + { + id: 'kings_road', + name: "King's Road", + nameRu: 'Королевский Тракт', + waves: 20, + difficulty: 'Новобранец', + unlockedBy: null, + x: '18%', y: '62%', + biomeColor: 'border-emerald-500/60', + biomeGlow: 'rgba(74,222,128,0.2)', + icon: '⚔', + }, + { + id: 'whispering_woods', + name: 'Whispering Woods', + nameRu: 'Шепчущий Лес', + waves: 20, + difficulty: 'Рыцарь', + unlockedBy: 'kings_road', + x: '46%', y: '36%', + biomeColor: 'border-gold/60', + biomeGlow: 'rgba(201,161,74,0.2)', + icon: '🌲', + }, + { + id: 'frostfall_pass', + name: 'Frostfall Pass', + nameRu: 'Морозный Перевал', + waves: 20, + difficulty: 'Архимаг', + unlockedBy: 'whispering_woods', + x: '70%', y: '22%', + biomeColor: 'border-frost/60', + biomeGlow: 'rgba(110,203,213,0.2)', + icon: '❄', + }, + { + id: 'obsidian_keep', + name: 'Obsidian Keep', + nameRu: 'Обсидиановая Цитадель', + waves: 10, + difficulty: 'Босс', + unlockedBy: 'frostfall_pass', + x: '84%', y: '56%', + biomeColor: 'border-arcane/60', + biomeGlow: 'rgba(155,77,232,0.22)', + icon: '💀', + }, +] + +const difficultyStyle: Record = { + 'Новобранец': 'text-emerald-400', + 'Рыцарь': 'text-gold', + 'Архимаг': 'text-rose-400', + 'Босс': 'text-arcane', +} + +export function CampaignMap() { + const setScreen = useGameStore((s) => s.setScreen) + const resetGame = useGameStore((s) => s.resetGame) + const setCurrentLevelId = useGameStore((s) => s.setCurrentLevelId) + const completedLevels = useMetaStore((s) => s.completedLevels) + + const isUnlocked = (level: Level) => + level.unlockedBy === null || completedLevels.includes(level.unlockedBy) + + const handlePlay = (levelId: string) => { + setCurrentLevelId(levelId) + resetGame() + setScreen('game') + } + + return ( +
+ + {/* Header */} +
+ +
+

Карта Кампании

+

+ {completedLevels.length} / {LEVELS.length} уровней завершено +

+
+
+
+ + {/* Map area */} +
+ + {/* Background atmosphere */} +
+
+ + {/* SVG connection lines */} + + + + + + + + + + + + + {/* Level nodes */} + {LEVELS.map((level) => { + const completed = completedLevels.includes(level.id) + const unlocked = isUnlocked(level) + + return ( +
+
+ + {/* Node button */} + + + {/* Label card */} +
+

+ {level.name} +

+

+ {level.nameRu} +

+
+
+ + {level.difficulty} + + · + + {level.waves} волн + +
+ {completed && ( +

+ ✦ Пройдено +

+ )} +
+
+
+ ) + })} + + {/* Bottom lore */} +
+

+ «Тьма не знает покоя — каждый рубеж должен устоять» +

+
+
+
+ ) +} diff --git a/src/ui/screens/GameOver.tsx b/src/ui/screens/GameOver.tsx new file mode 100644 index 0000000..53e4bb9 --- /dev/null +++ b/src/ui/screens/GameOver.tsx @@ -0,0 +1,134 @@ +import { useEffect, useRef } from 'react' +import { useGameStore } from '@/state/gameStore' +import { useMetaStore } from '@/state/metaStore' + +const ESSENCE_REWARDS: Record = { + kings_road: 5, + whispering_woods: 10, + frostfall_pass: 15, + obsidian_keep: 30, +} + +export function GameOver() { + const { won, wave, maxWaves, currentLevelId } = useGameStore() + const setScreen = useGameStore((s) => s.setScreen) + const resetGame = useGameStore((s) => s.resetGame) + const completeLevel = useMetaStore((s) => s.completeLevel) + const addEssence = useMetaStore((s) => s.addEssence) + const essenceEarned = won ? (ESSENCE_REWARDS[currentLevelId] ?? 0) : 0 + const persisted = useRef(false) + + useEffect(() => { + if (won && !persisted.current) { + persisted.current = true + completeLevel(currentLevelId) + if (essenceEarned > 0) addEssence(essenceEarned) + } + }, [won, currentLevelId, essenceEarned, completeLevel, addEssence]) + + return ( +
+ + {/* Background */} +
+ + {/* Atmospheric glow */} +
+ + {/* Card */} +
+ + {/* Corner marks */} + + + + + + {won ? ( + <> + {/* Victory */} +
+
👑
+
+
+
+
+ +
+

Победа

+

+ Нексус выстоял +

+
+ +

+ Все {maxWaves} волн отражены.
+ Королевство живёт ещё один день. +

+ + {essenceEarned > 0 && ( +
+ +
+

Эссенция

+

+{essenceEarned}

+
+
+ )} + + ) : ( + <> + {/* Defeat */} +
+
💀
+
+
+
+
+ +
+

Поражение

+

+ Нексус пал +

+
+ +

+ Тьма поглотила цитадель на волне {wave}.
+ Но архимаги не сдаются. +

+ + )} + +
+ + {/* Actions */} +
+ + +
+
+
+ ) +} diff --git a/src/ui/screens/GameScreen.tsx b/src/ui/screens/GameScreen.tsx new file mode 100644 index 0000000..d380af0 --- /dev/null +++ b/src/ui/screens/GameScreen.tsx @@ -0,0 +1,54 @@ +import { useEffect, useRef } from 'react' +import { initPixi, destroyPixi } from '@/game/rendering/PixiRoot' +import { createEngine, getEngine } from '@/game/core/GameEngine' +import { useGameStore } from '@/state/gameStore' +import { useSettingsStore } from '@/state/settingsStore' +import { GameHUD } from '@/ui/hud/GameHUD' +import { LEVEL_MAP } from '@/data/levels' + +export function GameScreen() { + const wrapperRef = useRef(null) + const setScreen = useGameStore((s) => s.setScreen) + const showFps = useSettingsStore((s) => s.showFps) + const currentLevelId = useGameStore((s) => s.currentLevelId) + + useEffect(() => { + if (!wrapperRef.current) return + const wrapper = wrapperRef.current + let cancelled = false + + const levelDef = LEVEL_MAP[currentLevelId] ?? LEVEL_MAP['kings_road'] + + const init = async () => { + await initPixi(wrapper) + if (cancelled) { destroyPixi(); return } + const engine = createEngine() + engine.setShowFps(showFps) + engine.loadLevel(levelDef, wrapper) + engine.start() + } + + init().catch((e: unknown) => { + if (e instanceof Error && e.message === 'PixiJS init cancelled') return + console.error(e) + }) + + return () => { + cancelled = true + getEngine()?.destroy() + destroyPixi() + } + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + getEngine()?.setShowFps(showFps) + }, [showFps]) + + return ( +
+ {/* PixiJS создаёт canvas внутри этого div динамически */} +
+ setScreen('menu')} /> +
+ ) +} diff --git a/src/ui/screens/MainMenu.tsx b/src/ui/screens/MainMenu.tsx new file mode 100644 index 0000000..1a7877d --- /dev/null +++ b/src/ui/screens/MainMenu.tsx @@ -0,0 +1,127 @@ +import { useEffect, useRef } from 'react' +import { useGameStore } from '@/state/gameStore' +import { useMetaStore } from '@/state/metaStore' +import { useSettingsStore } from '@/state/settingsStore' +import gsap from 'gsap' + +export function MainMenu() { + const setScreen = useGameStore((s) => s.setScreen) + const loadMeta = useMetaStore((s) => s.load) + const loadSettings = useSettingsStore((s) => s.load) + + const titleRef = useRef(null) + const navRef = useRef(null) + const bgRef = useRef(null) + + useEffect(() => { + loadMeta() + loadSettings() + const tl = gsap.timeline({ defaults: { ease: 'power2.out' } }) + tl.fromTo(bgRef.current, { opacity: 0 }, { opacity: 1, duration: 2.5, ease: 'power1.inOut' }) + tl.fromTo(titleRef.current, { opacity: 0, y: -24, filter: 'blur(6px)' }, + { opacity: 1, y: 0, filter: 'blur(0px)', duration: 1.1 }, '-=1.6') + tl.fromTo(navRef.current, { opacity: 0, y: 18 }, { opacity: 1, y: 0, duration: 0.8 }, '-=0.5') + }, [loadMeta, loadSettings]) + + return ( +
+ + {/* Animated background */} +
+
+
+
+
+
+
+
+ +
+ + {/* Main content */} +
+ + {/* Title */} +
+
+ + Arcanum + +
+
+

+ Wardens of
the Realm +

+ + +
+

+ Protect the Nexus · Command the Arcane +

+
+ + {/* Divider */} +
+
+ +
+
+ + {/* Navigation */} + + +

+ v0.8.0 · Arcanum Engine +

+
+
+ ) +} + +const RUNE_DATA = [ + { glyph: 'ᚠ', pos: { top: '12%', left: '7%' }, delay: '0s', size: 'text-xl' }, + { glyph: 'ᚢ', pos: { top: '22%', right: '9%' }, delay: '0.6s', size: 'text-lg' }, + { glyph: 'ᚦ', pos: { top: '68%', left: '5%' }, delay: '1.2s', size: 'text-2xl' }, + { glyph: 'ᚨ', pos: { top: '78%', right: '7%' }, delay: '1.8s', size: 'text-xl' }, + { glyph: 'ᚱ', pos: { top: '42%', left: '2%' }, delay: '0.9s', size: 'text-lg' }, + { glyph: 'ᚲ', pos: { top: '55%', right: '4%' }, delay: '2.1s', size: 'text-2xl' }, + { glyph: 'ᚷ', pos: { top: '8%', left: '22%' }, delay: '0.3s', size: 'text-lg' }, + { glyph: 'ᚹ', pos: { top: '88%', left: '18%' }, delay: '1.5s', size: 'text-xl' }, + { glyph: 'ᚺ', pos: { top: '5%', right: '24%' }, delay: '2.4s', size: 'text-lg' }, + { glyph: 'ᚾ', pos: { top: '92%', right: '20%' }, delay: '0.75s', size: 'text-2xl' }, + { glyph: 'ᛁ', pos: { top: '50%', left: '11%' }, delay: '3s', size: 'text-lg' }, + { glyph: 'ᛃ', pos: { top: '33%', right: '13%' }, delay: '1.9s', size: 'text-xl' }, +] + +function RuneParticles() { + return ( + <> + {RUNE_DATA.map((r, i) => ( +
+ {r.glyph} +
+ ))} + + ) +} diff --git a/src/ui/screens/Settings.tsx b/src/ui/screens/Settings.tsx new file mode 100644 index 0000000..82bb72b --- /dev/null +++ b/src/ui/screens/Settings.tsx @@ -0,0 +1,108 @@ +import { useGameStore } from '@/state/gameStore' +import { useSettingsStore, type Difficulty } from '@/state/settingsStore' + +function VolumeRow({ label, icon, value, onChange }: { + label: string; icon: string; value: number; onChange: (v: number) => void +}) { + return ( +
+ {icon} + {label} + onChange(parseFloat(e.target.value))} + className="flex-1 cursor-pointer" + /> + {Math.round(value * 100)}% +
+ ) +} + +const DIFFICULTIES: { id: Difficulty; label: string; icon: string; color: string; desc: string }[] = [ + { id: 'normal', label: 'Обычный', icon: '⚔', color: 'text-emerald-400', desc: 'Для новичков' }, + { id: 'heroic', label: 'Героический', icon: '🛡', color: 'text-gold', desc: '+35% HP, +10% скорость' }, + { id: 'nightmare', label: 'Кошмар', icon: '💀', color: 'text-rose-400', desc: '+75% HP, +25% скорость' }, +] + +function Section({ title }: { title: string }) { + return ( +
+ {title} +
+ ) +} + +export function Settings() { + const setScreen = useGameStore((s) => s.setScreen) + const { + masterVolume, sfxVolume, musicVolume, showFps, difficulty, + setMasterVolume, setSfxVolume, setMusicVolume, setShowFps, setDifficulty, + } = useSettingsStore() + + return ( +
+
+ +

Настройки

+
+
+ +
+
+ +
+
+ {DIFFICULTIES.map((d) => ( + + ))} +
+ +
+
+ + + +
+ +
+
+
+

Показать FPS

+

Счётчик кадров в секунду

+
+ +
+ +
+
+
+ ) +} diff --git a/src/ui/screens/TomeOfRunes.tsx b/src/ui/screens/TomeOfRunes.tsx new file mode 100644 index 0000000..57ccf6d --- /dev/null +++ b/src/ui/screens/TomeOfRunes.tsx @@ -0,0 +1,121 @@ +import { useGameStore } from '@/state/gameStore' +import { useMetaStore } from '@/state/metaStore' + +interface RuneDef { + id: string + symbol: string + name: string + description: string + cost: number + color: string + category: 'offense' | 'control' | 'support' +} + +const ALL_RUNES: RuneDef[] = [ + { id: 'pierce', symbol: 'ᚦ', name: 'Пронзание', description: 'Снаряды проходят сквозь врагов', cost: 0, color: 'text-parchment', category: 'offense' }, + { id: 'burn', symbol: 'ᚠ', name: 'Горение', description: 'Поджигает цель — DoT каждую секунду', cost: 0, color: 'text-ember', category: 'offense' }, + { id: 'chain', symbol: 'ᚱ', name: 'Цепь', description: 'Урон перепрыгивает на 2 соседние цели', cost: 80, color: 'text-gold', category: 'offense' }, + { id: 'fracture', symbol: 'ᚨ', name: 'Излом', description: 'Снижает броню цели на 2 секунды', cost: 110, color: 'text-amber-400', category: 'offense' }, + { id: 'shock', symbol: 'ᛃ', name: 'Разряд', description: 'Оглушает цель на 0.5 сек', cost: 100, color: 'text-yellow-300', category: 'control' }, + { id: 'frost', symbol: 'ᚾ', name: 'Иней', description: 'Замедляет врагов на 30%', cost: 50, color: 'text-frost', category: 'control' }, + { id: 'weight', symbol: 'ᛁ', name: 'Тяжесть', description: 'Замедляет бронированных врагов', cost: 70, color: 'text-stone-400', category: 'control' }, + { id: 'curse', symbol: 'ᚺ', name: 'Проклятие', description: 'Цель получает +20% урона', cost: 150, color: 'text-rose-600', category: 'control' }, + { id: 'seek', symbol: 'ᚷ', name: 'Поиск', description: 'Снаряды чуть наводятся на цель', cost: 60, color: 'text-emerald-400', category: 'support' }, + { id: 'echo', symbol: 'ᚹ', name: 'Эхо', description: 'Каждый 5-й выстрел — двойной урон', cost: 90, color: 'text-purple-400', category: 'support' }, + { id: 'vampire', symbol: 'ᚲ', name: 'Вампиризм', description: 'Башня восстанавливает 1 ОЗ стены за 10 убийств', cost: 120, color: 'text-rose-400', category: 'support' }, + { id: 'hex', symbol: 'ᚢ', name: 'Гекс', description: 'Отменяет регенерацию и неуязвимость', cost: 200, color: 'text-violet-400', category: 'support' }, +] + +const CAT_LABELS = { offense: 'Нападение', control: 'Контроль', support: 'Поддержка' } as const +const CAT_COLORS = { offense: 'text-ember', control: 'text-frost', support: 'text-emerald-400' } as const + +export function TomeOfRunes() { + const setScreen = useGameStore((s) => s.setScreen) + const { essence, unlockedRunes, unlockRune } = useMetaStore() + + return ( +
+
+ +

Том Рун

+
+ +
+

Эссенция

+

{essence}

+
+
+
+ +
+

+ Откройте новые руны за эссенцию, добытую в боях с боссами +

+ + {(['offense', 'control', 'support'] as const).map((cat) => ( +
+
+ + {CAT_LABELS[cat]} + +
+
+ {ALL_RUNES.filter((r) => r.category === cat).map((rune) => { + const unlocked = unlockedRunes.includes(rune.id) + const canAfford = essence >= rune.cost + return ( + canAfford && unlockRune(rune.id)} + /> + ) + })} +
+
+ ))} +
+
+ ) +} + +function RuneCard({ rune, unlocked, canAfford, onUnlock }: { + rune: RuneDef; unlocked: boolean; canAfford: boolean; onUnlock: () => void +}) { + return ( +
+ {unlocked && ( + <> + + + + )} + + {rune.symbol} + +

{rune.name}

+

{rune.description}

+
+ {unlocked ? ( + ✦ Изучена + ) : rune.cost === 0 ? ( + Начальная + ) : ( + + )} +
+ ) +} diff --git a/src/workers/pathfinding.worker.ts b/src/workers/pathfinding.worker.ts new file mode 100644 index 0000000..961daf6 --- /dev/null +++ b/src/workers/pathfinding.worker.ts @@ -0,0 +1,93 @@ +export type PathRequest = { + id: number + grid: boolean[][] // walkable[row][col] + cols: number + rows: number + start: { x: number; y: number } + goal: { x: number; y: number } +} + +export type PathResult = { + id: number + path: { x: number; y: number }[] | null +} + +type Node = { + x: number + y: number + g: number + h: number + f: number + parent: Node | null +} + +function heuristic(ax: number, ay: number, bx: number, by: number): number { + return Math.abs(ax - bx) + Math.abs(ay - by) +} + +function astar( + walkable: boolean[][], + cols: number, + rows: number, + start: { x: number; y: number }, + goal: { x: number; y: number } +): { x: number; y: number }[] | null { + const key = (x: number, y: number) => y * cols + x + + const open = new Map() + const closed = new Set() + + const startNode: Node = { + x: start.x, y: start.y, + g: 0, + h: heuristic(start.x, start.y, goal.x, goal.y), + f: 0, + parent: null, + } + startNode.f = startNode.g + startNode.h + open.set(key(start.x, start.y), startNode) + + const dirs = [[1,0],[-1,0],[0,1],[0,-1]] + + while (open.size > 0) { + // pick lowest f + let current: Node | null = null + for (const node of open.values()) { + if (!current || node.f < current.f) current = node + } + if (!current) break + + if (current.x === goal.x && current.y === goal.y) { + const path: { x: number; y: number }[] = [] + let n: Node | null = current + while (n) { path.unshift({ x: n.x, y: n.y }); n = n.parent } + return path + } + + open.delete(key(current.x, current.y)) + closed.add(key(current.x, current.y)) + + for (const [dx, dy] of dirs) { + const nx = current.x + dx + const ny = current.y + dy + if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue + if (!walkable[ny][nx]) continue + if (closed.has(key(nx, ny))) continue + + const g = current.g + 1 + const existing = open.get(key(nx, ny)) + if (!existing || g < existing.g) { + const h = heuristic(nx, ny, goal.x, goal.y) + const node: Node = { x: nx, y: ny, g, h, f: g + h, parent: current } + open.set(key(nx, ny), node) + } + } + } + return null +} + +self.onmessage = (e: MessageEvent) => { + const { id, grid, cols, rows, start, goal } = e.data + const path = astar(grid, cols, rows, start, goal) + self.postMessage({ id, path } satisfies PathResult) +} diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..9c4bb7d --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,103 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{ts,tsx}'], + theme: { + extend: { + colors: { + midnight: '#0E1220', + 'midnight-light': '#1a2035', + 'midnight-deep': '#070b14', + parchment: '#E9DCC0', + gold: '#C9A14A', + 'gold-dark': '#8B6914', + 'gold-bright': '#f0c060', + ember: '#E8702A', + frost: '#6ECBD5', + 'frost-dark': '#3a9aa8', + arcane: '#9b4de8', + 'arcane-dark': '#5a1ea0', + void: '#050810', + }, + fontFamily: { + cinzel: ['Cinzel', 'serif'], + garamond: ['"EB Garamond"', 'serif'], + }, + keyframes: { + 'rune-glow': { + '0%, 100%': { boxShadow: '0 0 8px 2px rgba(201, 161, 74, 0.4)' }, + '50%': { boxShadow: '0 0 20px 6px rgba(201, 161, 74, 0.8)' }, + }, + 'pulse-soft': { + '0%, 100%': { opacity: '0.65' }, + '50%': { opacity: '1' }, + }, + float: { + '0%, 100%': { transform: 'translateY(0px)' }, + '50%': { transform: 'translateY(-6px)' }, + }, + flicker: { + '0%, 100%': { opacity: '1' }, + '30%': { opacity: '0.75' }, + '60%': { opacity: '0.9' }, + '80%': { opacity: '0.7' }, + }, + 'spin-slow': { + from: { transform: 'rotate(0deg)' }, + to: { transform: 'rotate(360deg)' }, + }, + 'spin-slow-rev': { + from: { transform: 'rotate(0deg)' }, + to: { transform: 'rotate(-360deg)' }, + }, + shimmer: { + '0%': { backgroundPosition: '-200% center' }, + '100%': { backgroundPosition: '200% center' }, + }, + 'slide-up': { + from: { opacity: '0', transform: 'translateY(16px)' }, + to: { opacity: '1', transform: 'translateY(0)' }, + }, + 'fade-in': { + from: { opacity: '0' }, + to: { opacity: '1' }, + }, + 'scale-in': { + from: { opacity: '0', transform: 'scale(0.92)' }, + to: { opacity: '1', transform: 'scale(1)' }, + }, + 'glow-pulse-rose': { + '0%, 100%': { boxShadow: '0 0 8px rgba(244,63,94,0.4)' }, + '50%': { boxShadow: '0 0 22px rgba(244,63,94,0.9)' }, + }, + 'glow-pulse-frost': { + '0%, 100%': { boxShadow: '0 0 8px rgba(110,203,213,0.4)' }, + '50%': { boxShadow: '0 0 22px rgba(110,203,213,0.9)' }, + }, + 'sweep-in': { + from: { clipPath: 'inset(0 100% 0 0)' }, + to: { clipPath: 'inset(0 0% 0 0)' }, + }, + }, + animation: { + 'rune-glow': 'rune-glow 2s ease-in-out infinite', + 'pulse-soft': 'pulse-soft 3s ease-in-out infinite', + float: 'float 4s ease-in-out infinite', + flicker: 'flicker 3s ease-in-out infinite', + 'spin-slow': 'spin-slow 24s linear infinite', + 'spin-slow-rev': 'spin-slow-rev 18s linear infinite', + shimmer: 'shimmer 2.5s linear infinite', + 'slide-up': 'slide-up 0.4s ease-out', + 'fade-in': 'fade-in 0.5s ease-out', + 'scale-in': 'scale-in 0.35s cubic-bezier(0.16,1,0.3,1)', + 'glow-pulse-rose': 'glow-pulse-rose 2s ease-in-out infinite', + 'glow-pulse-frost': 'glow-pulse-frost 2s ease-in-out infinite', + 'sweep-in': 'sweep-in 0.6s ease-out forwards', + }, + backgroundImage: { + 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', + 'gradient-conic': 'conic-gradient(var(--tw-gradient-stops))', + }, + }, + }, + plugins: [], +} diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..b4c2f23 --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "esnext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..d3c52ea --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..3c2d415 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import path from 'path' + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + worker: { + format: 'es', + }, +})