Compare commits
30 Commits
bc472d0f77
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c92a07eeb9 | ||
|
|
d9213b6be0 | ||
|
|
91d4e4d730 | ||
|
|
b295f3e1fd | ||
|
|
5f78aa1444 | ||
|
|
0cd995c817 | ||
|
|
1b2cc0cd86 | ||
|
|
6ba0746bb9 | ||
|
|
3c24205e72 | ||
|
|
7d52d749a3 | ||
|
|
0d35cdcc73 | ||
|
|
35f8905921 | ||
|
|
493748f2b0 | ||
|
|
3d4f710cb0 | ||
|
|
56c96798e3 | ||
|
|
5b7dbb4df3 | ||
|
|
22e6c6bcee | ||
|
|
8dd69e8fd2 | ||
|
|
9521b7951c | ||
|
|
324be5e643 | ||
|
|
7e46d1ed1d | ||
|
|
d173ada466 | ||
|
|
0396170303 | ||
|
|
b097ce738f | ||
|
|
e77b9df6e4 | ||
|
|
cf36c0adce | ||
|
|
0c0635c93b | ||
|
|
c4993e9eee | ||
|
|
f5898d30f7 | ||
|
|
5b26820e21 |
@@ -12,26 +12,29 @@ For every task, follow this sequence:
|
|||||||
3. **Implementation** — write code to make tests pass
|
3. **Implementation** — write code to make tests pass
|
||||||
4. **Verify** — `npm run test:run` for logic, Playwright screenshot for visuals
|
4. **Verify** — `npm run test:run` for logic, Playwright screenshot for visuals
|
||||||
5. **Commit** — `git add -A && git commit` after each successful step
|
5. **Commit** — `git add -A && git commit` after each successful step
|
||||||
|
6. **Push** — `git push` after every commit (never accumulate unpushed commits)
|
||||||
|
|
||||||
## Commit Discipline
|
## Commit & Push Discipline
|
||||||
- Commit after EVERY successful step (tests green or visual verified)
|
- Commit after EVERY successful step (tests green or visual verified)
|
||||||
|
- Push immediately after every commit — code must always be on remote
|
||||||
- Small, focused commits — one concern per commit
|
- Small, focused commits — one concern per commit
|
||||||
- Never accumulate uncommitted work across multiple tasks
|
- Never accumulate uncommitted or unpushed work across multiple tasks
|
||||||
- Commit message format: concise "what + why" in English
|
- Commit message format: concise "what + why" in English
|
||||||
|
|
||||||
## Visual Verification
|
## Visual Verification
|
||||||
After ANY visual change:
|
After ANY visual change:
|
||||||
1. Ensure dev server is running (`npm run dev`)
|
1. Ensure dev server is running (`npm run dev`)
|
||||||
2. Navigate to `http://localhost:5173`
|
2. Navigate to `http://localhost:5173`
|
||||||
3. Take screenshot to see result
|
3. Take screenshot to `screenshots/` folder (gitignored)
|
||||||
4. Check browser console for errors
|
4. Check browser console for errors
|
||||||
5. Fix issues before committing
|
5. Fix issues before committing
|
||||||
|
|
||||||
## After Completing a Task
|
## After Completing a Task
|
||||||
1. Verify it works (test or screenshot)
|
1. Verify it works (test or screenshot)
|
||||||
2. Commit immediately
|
2. Commit immediately
|
||||||
3. Update `PROGRESS.md` — mark task complete
|
3. Push immediately (`git push`)
|
||||||
4. Check `IMPLEMENTATION-PLAN.md` for what's next
|
4. Update `PROGRESS.md` — mark task complete
|
||||||
|
5. Check `IMPLEMENTATION-PLAN.md` for what's next
|
||||||
|
|
||||||
## Code Standards
|
## Code Standards
|
||||||
- TypeScript strict mode — no `any`, no unsafe `as` casts
|
- TypeScript strict mode — no `any`, no unsafe `as` casts
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -3,3 +3,5 @@ dist/
|
|||||||
.vite/
|
.vite/
|
||||||
*.local
|
*.local
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
screenshots/
|
||||||
|
.playwright-mcp/
|
||||||
|
|||||||
@@ -122,7 +122,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 5: Creatures & Ecology
|
## Phase 5: Creatures & Ecology ✅
|
||||||
|
|
||||||
**Цель:** Живая экосистема в Каталитических Пустошах
|
**Цель:** Живая экосистема в Каталитических Пустошах
|
||||||
**Зависимости:** Phase 2, Phase 3
|
**Зависимости:** Phase 2, Phase 3
|
||||||
@@ -145,7 +145,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 6: Run Cycle
|
## Phase 6: Run Cycle ✅
|
||||||
|
|
||||||
**Цель:** Полный рогалик-цикл: рождение → смерть → перерождение
|
**Цель:** Полный рогалик-цикл: рождение → смерть → перерождение
|
||||||
**Зависимости:** Phase 3, Phase 4, Phase 1
|
**Зависимости:** Phase 3, Phase 4, Phase 1
|
||||||
@@ -172,7 +172,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 7: Mycelium
|
## Phase 7: Mycelium ✅
|
||||||
|
|
||||||
**Цель:** Подземная сеть, связывающая раны
|
**Цель:** Подземная сеть, связывающая раны
|
||||||
**Зависимости:** Phase 6
|
**Зависимости:** Phase 6
|
||||||
@@ -195,7 +195,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 8: First Archont — Ouroboros
|
## Phase 8: First Archont — Ouroboros ✅
|
||||||
|
|
||||||
**Цель:** Первый босс-бой (головоломка, не рефлексы)
|
**Цель:** Первый босс-бой (головоломка, не рефлексы)
|
||||||
**Зависимости:** Phase 5, Phase 6
|
**Зависимости:** Phase 5, Phase 6
|
||||||
@@ -238,9 +238,9 @@
|
|||||||
|
|
||||||
| Фаза | Содержание |
|
| Фаза | Содержание |
|
||||||
|-------|-----------|
|
|-------|-----------|
|
||||||
| Phase 9 | +2 биома (Кинетические Горы, Вердантовые Леса), +20 элементов, +100 реакций |
|
| Phase 9 ✅ | +2 биома (Кинетические Горы, Вердантовые Леса), +20 элементов, +100 реакций |
|
||||||
| Phase 10 | +3 школы (Механик, Натуралист, Навигатор), принципы физики и биологии |
|
| Phase 10 ✅ | +3 школы (Механик, Натуралист, Навигатор), принципы физики и биологии |
|
||||||
| Phase 11 | Великий цикл (7 ранов): следы между ранами, Великое Обновление |
|
| Phase 11 ✅ | Великий цикл (7 ранов): следы между ранами, Великое Обновление |
|
||||||
| Phase 12 | +3 Архонта (Спора Прайма, Энтропа, Когнитон) |
|
| Phase 12 | +3 Архонта (Спора Прайма, Энтропа, Когнитон) |
|
||||||
| Phase 13 | Оставшиеся биомы + школы + существа |
|
| Phase 13 | Оставшиеся биомы + школы + существа |
|
||||||
| Phase 14 | Сердце Синтеза (финальный регион), нарратив |
|
| Phase 14 | Сердце Синтеза (финальный регион), нарратив |
|
||||||
|
|||||||
124
PROGRESS.md
124
PROGRESS.md
@@ -1,7 +1,7 @@
|
|||||||
# Synthesis — Development Progress
|
# Synthesis — Development Progress
|
||||||
|
|
||||||
> **Last updated:** 2026-02-12
|
> **Last updated:** 2026-02-12
|
||||||
> **Current phase:** Phase 3 ✅ → Ready for Phase 4
|
> **Current phase:** Phase 11 ✅ → Great Cycle Complete
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -48,23 +48,119 @@
|
|||||||
- [x] 3.7 Minimap — canvas-based 160x160 overview, viewport indicator, border (`src/world/minimap.ts`)
|
- [x] 3.7 Minimap — canvas-based 160x160 overview, viewport indicator, border (`src/world/minimap.ts`)
|
||||||
- [x] Unit tests — 21 passing (`tests/world.test.ts`)
|
- [x] Unit tests — 21 passing (`tests/world.test.ts`)
|
||||||
|
|
||||||
|
### Phase 4: Player Systems ✅
|
||||||
|
- [x] 4.1 Player entity + WASD controller (`src/player/input.ts`, `src/player/collision.ts`, `src/player/spawn.ts`, `src/player/factory.ts`)
|
||||||
|
- [x] 4.2 Inventory — weight-based, element stacking, AMU mass from registries (`src/player/inventory.ts`)
|
||||||
|
- [x] 4.3 Element collection from world objects — resource entities, proximity interaction, E key (`src/player/interaction.ts`, `src/world/resources.ts`)
|
||||||
|
- [x] 4.4 Crafting — chemistry engine integration, inventory consume/produce, condition checking (`src/player/crafting.ts`)
|
||||||
|
- [x] 4.5 Projectile system — throw elements toward mouse, lifetime + tile collision (`src/player/projectile.ts`)
|
||||||
|
- [x] 4.6 Quick slots — 4 hotkeys, auto-assign on pickup, active slot for throw (`src/player/quickslots.ts`)
|
||||||
|
- [x] 4.7 HUD — UIScene overlay: health bar, quick slots, inventory info, controls hint (`src/scenes/UIScene.ts`)
|
||||||
|
- [x] Unit tests — 39 player + 28 inventory + 12 interaction + 11 crafting + 13 projectile + 15 quickslots + 8 UI = 126 tests (222 total)
|
||||||
|
|
||||||
|
### Phase 5: Creatures & Ecology ✅
|
||||||
|
- [x] 5.1 Creature data — 3 species: Crystallids, Acidophiles, Reagents (`src/data/creatures.json`)
|
||||||
|
- [x] 5.2 AI behavior — FSM: idle → wander → feed → flee → attack (`src/creatures/ai.ts`)
|
||||||
|
- [x] 5.3 Metabolism — energy drain, feeding from resources, starvation damage (`src/creatures/metabolism.ts`)
|
||||||
|
- [x] 5.4 Population dynamics — counting, reproduction, initial spawning (`src/creatures/population.ts`)
|
||||||
|
- [x] 5.5 Life cycle — egg → youth → mature → aging → natural death (`src/creatures/lifecycle.ts`)
|
||||||
|
- [x] 5.6 Interaction — projectile collision (armor), observation, creature→player melee (`src/creatures/interaction.ts`)
|
||||||
|
- [x] 5.7 Ecosystem test — populations fluctuate, don't die out (`tests/ecosystem.test.ts`)
|
||||||
|
- [x] 5.8 GameScene integration — all creature systems in update loop, debug overlay
|
||||||
|
- [x] ECS components: Creature, AI, Metabolism, LifeCycle (`src/ecs/components.ts`)
|
||||||
|
- [x] Species registry with string/numeric ID lookup (`src/creatures/types.ts`)
|
||||||
|
- [x] Creature factory (`src/creatures/factory.ts`)
|
||||||
|
- [x] Unit tests — 51 creature + 16 interaction + 5 ecosystem = 72 tests (293 total)
|
||||||
|
|
||||||
|
### Phase 6: Run Cycle ✅
|
||||||
|
- [x] 6.1 Spore Cradle — CradleScene: school selection UI, floating spore particles, meta display (`src/scenes/CradleScene.ts`)
|
||||||
|
- [x] 6.2 Death trigger — player death → camera fade → DeathScene transition (`src/scenes/GameScene.ts`)
|
||||||
|
- [x] 6.3 Death animation — body decomposes into real elements (65% O, 18% C, 10% H...), element labels, Mycelium threads absorbing particles (`src/scenes/DeathScene.ts`)
|
||||||
|
- [x] 6.4 Moment Between — WebGL Mandelbrot/Julia hybrid shader with morphing + zoom, canvas fallback for non-WebGL (`src/scenes/FractalScene.ts`)
|
||||||
|
- [x] 6.5 Meta-progression — Codex (permanent knowledge), spores (currency), run history, IndexedDB persistence (`src/run/meta.ts`, `src/run/persistence.ts`)
|
||||||
|
- [x] 6.6 Run phases — state machine: Awakening → Exploration → Escalation → Crisis → Resolution, auto-advance on timers (`src/run/state.ts`)
|
||||||
|
- [x] 6.7 Escalation — creature speed/aggro/damage multipliers, reaction instability, environmental damage at high entropy (`src/run/escalation.ts`)
|
||||||
|
- [x] 6.8 Crisis: Chemical Plague — chain reaction poisons atmosphere, progressive player damage, toxic green tint overlay, CaO neutralization mechanics (`src/run/crisis.ts`)
|
||||||
|
- [x] 6.9 School: Alchemist — starting kit (H×5, O×5, C×3, Na×3, S×2, Fe×2), chemical equilibrium principle (`src/data/schools.json`)
|
||||||
|
- [x] Scene flow: BootScene → CradleScene → GameScene → DeathScene → FractalScene → CradleScene (full loop verified)
|
||||||
|
- [x] Texture cleanup for multi-run support (tilemap + minimap textures removed before recreation)
|
||||||
|
- [x] Unit tests — 42 run-cycle + 14 escalation = 56 tests (349 total)
|
||||||
|
|
||||||
|
### Phase 7: Mycelium ✅
|
||||||
|
- [x] 7.1 Mycelium Graph — persistent knowledge network (nodes, edges, deposit/strengthen/query) (`src/mycelium/graph.ts`)
|
||||||
|
- [x] 7.2 Fungal Nodes — ECS entities on world map, glowing bioluminescent spots, pulsing animation (`src/mycelium/nodes.ts`)
|
||||||
|
- [x] 7.3 Knowledge Recording — deposit run discoveries into Mycelium (auto on death + manual at nodes) (`src/mycelium/knowledge.ts`)
|
||||||
|
- [x] 7.4 Knowledge Extraction — memory flashes from past runs, weighted by node strength, Russian text templates (`src/mycelium/knowledge.ts`)
|
||||||
|
- [x] 7.5 Mycosis — visual distortion (tint overlay) on prolonged fungal node contact, reveals hidden info at threshold (`src/mycelium/mycosis.ts`)
|
||||||
|
- [x] 7.6 Spore Shop — Cradle integration: spend spores for starting bonuses (extra health, elements, knowledge boost) (`src/mycelium/shop.ts`)
|
||||||
|
- [x] ECS component: FungalNode (nodeIndex, glowPhase, interactRange) (`src/ecs/components.ts`)
|
||||||
|
- [x] Data: mycelium config, spore bonuses, memory templates (`src/data/mycelium.json`)
|
||||||
|
- [x] MetaState extended with MyceliumGraphData, IndexedDB persistence updated
|
||||||
|
- [x] GameScene integration: node spawning, glow rendering, E-key interaction, mycosis overlay, memory flash display
|
||||||
|
- [x] CradleScene integration: spore shop UI, Mycelium stats display, purchased effects passed to GameScene
|
||||||
|
- [x] Unit tests — 36 passing (`tests/mycelium.test.ts`) (385 total)
|
||||||
|
|
||||||
|
### Phase 8: First Archont — Ouroboros ✅
|
||||||
|
- [x] 8.1 Ouroboros entity — Boss data JSON, BossData type, Boss ECS component, entity factory (`src/boss/factory.ts`, `src/data/bosses.json`)
|
||||||
|
- [x] 8.2 Pattern mechanics — 4-phase cyclical AI: Coil → Spray → Lash → Digest, escalating difficulty (10% faster per cycle, caps at 5), regeneration + armor systems (`src/boss/ai.ts`)
|
||||||
|
- [x] 8.3 Arena — circular generated room (21×21 tiles), acid pools, crystal walls, 4 mineral deposits, boss center / player south spawn (`src/boss/arena.ts`)
|
||||||
|
- [x] 8.4 Multiple solutions — 3 real-chemistry victory paths: NaOH acid-base neutralization (3× dmg during Spray), direct damage during Digest vulnerability, Hg catalyst poisoning (permanently reduces regen+armor) (`src/boss/victory.ts`)
|
||||||
|
- [x] 8.5 Reward — Archont's Memory lore entry in Codex, spore reward (100 base, +50% chemical, +100% catalytic), CodexEntry type extended with 'boss' (`src/boss/reward.ts`)
|
||||||
|
- [x] BossArenaScene — full boss fight scene with phase-based attacks (Coil: chase, Spray: rotating acid projectiles, Lash: area sweep, Digest: immobile+vulnerable), boss health bar + phase indicator, victory/death handling (`src/scenes/BossArenaScene.ts`)
|
||||||
|
- [x] GameScene integration — Resolution phase completion triggers arena transition, inventory/quickslots/health carried over
|
||||||
|
- [x] Scene flow extended: GameScene → BossArenaScene → DeathScene (on death or victory)
|
||||||
|
- [x] Unit tests — 70 passing (`tests/boss.test.ts`) (455 total)
|
||||||
|
|
||||||
|
### Phase 9: Biome Expansion ✅
|
||||||
|
- [x] 9.1 New elements — 20 additional real elements (Li, B, F, Ne, Ar, Ti, Cr, Mn, Co, Ni, As, Br, Ag, I, Ba, W, Pt, Pb, Bi, U) → 40 total
|
||||||
|
- [x] 9.2 New compounds — 39 new compounds (acids, salts, oxides, organics) → 64 total
|
||||||
|
- [x] 9.3 New reactions — 85 new reactions (synthesis, combustion, acid-base, redox, decomposition, replacement) → 119 total
|
||||||
|
- [x] 9.4 Kinetic Mountains biome — physics/mechanics themed: chasms, gear floors, magnetic fields, steam vents, ore deposits
|
||||||
|
- [x] 9.5 Verdant Forests biome — biology/ecology themed: bogs, toxic blooms, ancient trees, mycelium carpet, herb patches
|
||||||
|
- [x] 9.6 Biome selection — CradleScene biome picker UI, biomeId passed to GameScene, generic world generator
|
||||||
|
- [x] 9.7 New creatures — 6 species: Pendulums/Mechanoids/Resonators (mountains), Symbiotes/Mimics/Spore-bearers (forests)
|
||||||
|
- [x] 9.8 World generator genericized — tile lookup by property (interactive/resource) instead of hardcoded names
|
||||||
|
- [x] Chemistry types extended — `actinide` element category for Uranium
|
||||||
|
- [x] Species types extended — `biome` field, `SpeciesId` enum expanded (0–8)
|
||||||
|
- [x] GameScene filters creatures by selected biome
|
||||||
|
- [x] Unit tests — 32 new tests (487 total)
|
||||||
|
|
||||||
|
### Phase 10: Schools ✅
|
||||||
|
- [x] 10.1 SchoolData type extended — `bonuses`, `unlockCondition`, `ResolvedSchoolBonuses` interface (`src/run/types.ts`)
|
||||||
|
- [x] 10.2 3 new schools — Mechanic, Naturalist, Navigator with real science principles (`src/data/schools.json`)
|
||||||
|
- [x] 10.3 School unlock system — data-driven conditions: elements_discovered, creatures_discovered, runs_completed (`src/run/meta.ts`)
|
||||||
|
- [x] 10.4 School bonuses resolver — `getSchoolBonuses()` returns complete multiplier set with defaults (`src/run/meta.ts`)
|
||||||
|
- [x] 10.5 Bonus integration — movement speed (Navigator), projectile damage (Mechanic), creature aggro range (Naturalist), reaction efficiency (Alchemist)
|
||||||
|
- [x] 10.6 CradleScene updated — all 4 schools displayed, locked schools grayed out with lock icon + unlock hint, bonus indicators shown
|
||||||
|
- [x] 10.7 System signatures updated — `playerInputSystem`, `aiSystem`, `creatureProjectileSystem` accept optional multiplier params
|
||||||
|
- [x] Unit tests — 24 new tests (511 total): school data, unlock conditions (cumulative, multi-school, persistence), bonuses per school
|
||||||
|
|
||||||
|
### Phase 11: Great Cycle ✅
|
||||||
|
- [x] 11.1 GreatCycleState types — CycleTheme enum, RunTrace, GreatCycleState, CycleWorldModifiers (`src/run/types.ts`)
|
||||||
|
- [x] 11.2 Great Cycle engine — cycle tracking, theme resolution, trace recording, renewal, world modifiers (`src/run/cycle.ts`)
|
||||||
|
- [x] 11.3 MetaState extended — `greatCycle` field, persistence updated, RunState extended with biomeId/worldSeed/deathPosition
|
||||||
|
- [x] 11.4 Run trace recording — death position, school, biome, discoveries saved per run via `applyRunResults`
|
||||||
|
- [x] 11.5 World trace placement — WorldTrace ECS component, death site markers (dark red), discovery markers (blue), glow animation (`src/world/traces.ts`)
|
||||||
|
- [x] 11.6 Cycle narrative data — 6 themes with Russian lore fragments, cradle quotes, renewal messages (`src/data/cycle-narrative.json`)
|
||||||
|
- [x] 11.7 RenewalScene — special scene between great cycles: Mycelium particles, staged text reveals, cycle transition, lore, maturation display (`src/scenes/RenewalScene.ts`)
|
||||||
|
- [x] 11.8 Mycelium maturation — strengthens all nodes on renewal, caps at 1.0, cumulative across cycles
|
||||||
|
- [x] 11.9 CradleScene integration — "Великий Цикл N: Тема | Ран X/7", narrative quote per theme
|
||||||
|
- [x] 11.10 UIScene integration — cycle info bar + run phase display below health
|
||||||
|
- [x] 11.11 GameScene integration — world trace spawning, trace glow rendering, death position recording, cycle info to registry
|
||||||
|
- [x] 11.12 Scene flow extended — Fractal → RenewalScene (on 7th run) → Cradle, or Fractal → Cradle (normal)
|
||||||
|
- [x] Unit tests — 51 new tests (562 total): cycle init, themes, traces, advancement, renewal, world modifiers, narrative data, full integration
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## In Progress
|
## In Progress
|
||||||
|
|
||||||
_None — ready to begin Phase 4_
|
_None — Phase 11 complete_
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Up Next: Phase 4 — Player Systems
|
## Up Next: Phase 12+
|
||||||
|
|
||||||
- [ ] 4.1 Player entity + WASD controller
|
_(See IMPLEMENTATION-PLAN.md for Beyond Vertical Slice)_
|
||||||
- [ ] 4.2 Inventory (weight-based, element stacking)
|
|
||||||
- [ ] 4.3 Element collection from world objects
|
|
||||||
- [ ] 4.4 Crafting (chemistry engine integration)
|
|
||||||
- [ ] 4.5 Projectile system (throw elements/compounds)
|
|
||||||
- [ ] 4.6 Quick slots (1-2-3-4 hotkeys)
|
|
||||||
- [ ] 4.7 HUD (UIScene: health ring, inventory bar, element info)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -82,3 +178,11 @@ None
|
|||||||
| 2 | 2026-02-12 | Phase 1 | Chemistry engine: 20 elements, 25 compounds, 34 reactions, engine with O(1) lookup + educational failures, 35 tests passing |
|
| 2 | 2026-02-12 | Phase 1 | Chemistry engine: 20 elements, 25 compounds, 34 reactions, engine with O(1) lookup + educational failures, 35 tests passing |
|
||||||
| 3 | 2026-02-12 | Phase 2 | ECS foundation: world + time, 5 components, movement + bounce + health systems, Phaser bridge (polling sync), entity factory, GameScene with 20 bouncing circles at 60fps, 39 tests passing |
|
| 3 | 2026-02-12 | Phase 2 | ECS foundation: world + time, 5 components, movement + bounce + health systems, Phaser bridge (polling sync), entity factory, GameScene with 20 bouncing circles at 60fps, 39 tests passing |
|
||||||
| 4 | 2026-02-12 | Phase 3 | World generation: simplex noise (seeded), 80x80 tilemap with 8 tile types, Catalytic Wastes biome, camera WASD+zoom, minimap with viewport indicator, 21 tests passing (95 total) |
|
| 4 | 2026-02-12 | Phase 3 | World generation: simplex noise (seeded), 80x80 tilemap with 8 tile types, Catalytic Wastes biome, camera WASD+zoom, minimap with viewport indicator, 21 tests passing (95 total) |
|
||||||
|
| 5 | 2026-02-12 | Phase 4 | Player systems: WASD movement + tile collision, weight-based inventory, resource collection, crafting via chemistry engine, projectile throw, 4 quick slots, UIScene HUD overlay (health bar, slots, inventory), 126 new tests (222 total) |
|
||||||
|
| 6 | 2026-02-12 | Phase 5 | Creatures & Ecology: 3 species (Crystallid/Acidophile/Reagent), FSM AI (idle/wander/feed/flee/attack), metabolism (energy drain/feeding/starvation), life cycle (egg→youth→mature→aging→death), population dynamics, projectile-creature collision with armor, creature→player melee, ecosystem simulation test, 72 new tests (293 total) |
|
||||||
|
| 7 | 2026-02-12 | Phase 6 | Run Cycle: full roguelike loop (Cradle→Game→Death→Fractal→Cradle), school selection (Alchemist), meta-progression (Codex/spores/IndexedDB), run phases with auto-advance, escalation effects (creature aggression/env damage), Chemical Plague crisis with neutralization, death animation (real body composition), WebGL fractal shader, 56 new tests (349 total) |
|
||||||
|
| 8 | 2026-02-12 | Phase 7 | Mycelium: persistent knowledge graph (nodes/edges/strength), fungal node ECS entities with glow animation, knowledge deposit (auto on death + manual at nodes), memory flash extraction (weighted by strength, Russian templates), mycosis visual effect (tint overlay + reveal threshold), spore shop in Cradle (5 bonuses: health/elements/knowledge), MetaState+IndexedDB persistence updated, GameScene+CradleScene integration, 36 new tests (385 total) |
|
||||||
|
| 9 | 2026-02-12 | Phase 8 | Ouroboros boss fight: 4-phase cyclical AI (Coil/Spray/Lash/Digest) with escalating difficulty, 3 chemistry-based victory paths (NaOH neutralization, direct damage, Hg catalyst poison), circular arena generator, BossArenaScene with attacks+collision+UI, Archont's Memory lore reward, CodexEntry extended, GameScene Resolution→arena trigger, 70 new tests (455 total) |
|
||||||
|
| 10 | 2026-02-12 | Phase 9 | Biome expansion: +20 elements (40 total), +39 compounds (64 total), +85 reactions (119 total), 2 new biomes (Kinetic Mountains + Verdant Forests), biome selection in CradleScene, 6 new creature species (3 per new biome), generic world generator, 32 new tests (487 total) |
|
||||||
|
| 11 | 2026-02-12 | Phase 10 | Schools: +3 schools (Mechanic/Naturalist/Navigator), data-driven unlock system (elements/creatures/runs), school bonuses (projectile dmg, movement speed, aggro range, reaction efficiency), CradleScene shows locked schools with hints, system multiplier params, 24 new tests (511 total) |
|
||||||
|
| 12 | 2026-02-12 | Phase 11 | Great Cycle: 7-run macro cycles with traces between runs, GreatCycleState (cycle number/theme/traces), RunTrace recording (death position/school/biome/discoveries), WorldTrace ECS entities (death markers + discovery sites with glow), RenewalScene (Mycelium particles/staged text/cycle transition), 6 narrative themes (Awakening→Synthesis) with Russian lore, Mycelium maturation on renewal, CradleScene+UIScene+GameScene cycle display, 51 new tests (562 total) |
|
||||||
|
|||||||
BIN
boot-screen.png
BIN
boot-screen.png
Binary file not shown.
|
Before Width: | Height: | Size: 26 KiB |
152
src/boss/ai.ts
Normal file
152
src/boss/ai.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* Boss AI System — cyclical phase logic for Archon encounters
|
||||||
|
*
|
||||||
|
* The boss cycles through 4 phases (Coil → Spray → Lash → Digest),
|
||||||
|
* each cycle becoming faster. During Digest, the boss is vulnerable.
|
||||||
|
* Between cycles, the boss regenerates health (self-catalysis).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
BossPhase,
|
||||||
|
BOSS_PHASE_COUNT,
|
||||||
|
type BossData,
|
||||||
|
type BossState,
|
||||||
|
type BossPhaseEvent,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
/** Create initial boss state from boss data */
|
||||||
|
export function createBossState(boss: BossData): BossState {
|
||||||
|
const initialDuration = getEffectivePhaseDuration(
|
||||||
|
{ cycleCount: 0 } as BossState, boss, BossPhase.Coil,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
bossId: boss.id,
|
||||||
|
health: boss.health,
|
||||||
|
maxHealth: boss.health,
|
||||||
|
currentPhase: BossPhase.Coil,
|
||||||
|
phaseTimer: initialDuration,
|
||||||
|
cycleCount: 0,
|
||||||
|
catalystStacks: 0,
|
||||||
|
defeated: false,
|
||||||
|
victoryMethod: null,
|
||||||
|
totalDamageDealt: 0,
|
||||||
|
chemicalDamageDealt: 0,
|
||||||
|
directDamageDealt: 0,
|
||||||
|
catalystDamageDealt: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the effective phase duration, accounting for cycle speedup.
|
||||||
|
* Each completed cycle multiplies duration by phaseSpeedupPerCycle.
|
||||||
|
* Caps at maxCycles to prevent impossibly short phases.
|
||||||
|
*/
|
||||||
|
export function getEffectivePhaseDuration(
|
||||||
|
state: Pick<BossState, 'cycleCount'>,
|
||||||
|
boss: BossData,
|
||||||
|
phase: BossPhase,
|
||||||
|
): number {
|
||||||
|
const baseDuration = boss.phaseDurations[phase];
|
||||||
|
const effectiveCycles = Math.min(state.cycleCount, boss.maxCycles);
|
||||||
|
return baseDuration * Math.pow(boss.phaseSpeedupPerCycle, effectiveCycles);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get effective armor value, accounting for catalyst poison stacks.
|
||||||
|
* During vulnerable phases: uses armorVulnerable as base.
|
||||||
|
* During normal phases: uses armor as base.
|
||||||
|
* Catalyst stacks reduce armor further.
|
||||||
|
*/
|
||||||
|
export function getEffectiveArmor(
|
||||||
|
state: BossState,
|
||||||
|
boss: BossData,
|
||||||
|
): number {
|
||||||
|
const isVulnerable = boss.vulnerablePhases.includes(state.currentPhase);
|
||||||
|
const baseArmor = isVulnerable ? boss.armorVulnerable : boss.armor;
|
||||||
|
const reduction = state.catalystStacks * boss.catalystArmorReduction;
|
||||||
|
return Math.max(0, baseArmor - reduction);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get effective regeneration rate, accounting for catalyst poison.
|
||||||
|
* Each catalyst stack permanently reduces regen.
|
||||||
|
*/
|
||||||
|
export function getEffectiveRegen(
|
||||||
|
state: BossState,
|
||||||
|
boss: BossData,
|
||||||
|
): number {
|
||||||
|
const reduction = state.catalystStacks * boss.catalystRegenReduction;
|
||||||
|
return Math.max(0, boss.regenPerSecond - reduction);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the boss is currently in a vulnerable phase.
|
||||||
|
*/
|
||||||
|
export function isVulnerable(state: BossState, boss: BossData): boolean {
|
||||||
|
return boss.vulnerablePhases.includes(state.currentPhase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update boss phase timer and cycle through phases.
|
||||||
|
* Returns events for phase changes, cycle completions, and attacks.
|
||||||
|
*
|
||||||
|
* @param state - Mutable boss state
|
||||||
|
* @param boss - Immutable boss data
|
||||||
|
* @param delta - Time elapsed in ms
|
||||||
|
* @returns Array of events that occurred this tick
|
||||||
|
*/
|
||||||
|
export function updateBossPhase(
|
||||||
|
state: BossState,
|
||||||
|
boss: BossData,
|
||||||
|
delta: number,
|
||||||
|
): BossPhaseEvent[] {
|
||||||
|
if (state.defeated) return [];
|
||||||
|
|
||||||
|
const events: BossPhaseEvent[] = [];
|
||||||
|
|
||||||
|
// Apply regeneration
|
||||||
|
const regen = getEffectiveRegen(state, boss);
|
||||||
|
if (regen > 0) {
|
||||||
|
state.health = Math.min(state.maxHealth, state.health + regen * (delta / 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count down phase timer
|
||||||
|
state.phaseTimer -= delta;
|
||||||
|
|
||||||
|
// Phase transition
|
||||||
|
while (state.phaseTimer <= 0) {
|
||||||
|
const nextPhase = ((state.currentPhase + 1) % BOSS_PHASE_COUNT) as BossPhase;
|
||||||
|
|
||||||
|
// Cycle completion: when Digest ends (wrapping back to Coil)
|
||||||
|
if (state.currentPhase === BossPhase.Digest) {
|
||||||
|
state.cycleCount += 1;
|
||||||
|
events.push({
|
||||||
|
type: 'cycle_complete',
|
||||||
|
phase: nextPhase,
|
||||||
|
cycleCount: state.cycleCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
state.currentPhase = nextPhase;
|
||||||
|
const newDuration = getEffectivePhaseDuration(state, boss, nextPhase);
|
||||||
|
state.phaseTimer += newDuration;
|
||||||
|
|
||||||
|
events.push({
|
||||||
|
type: 'phase_change',
|
||||||
|
phase: nextPhase,
|
||||||
|
cycleCount: state.cycleCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boss attacks during Spray and Lash phases
|
||||||
|
if (state.currentPhase === BossPhase.Spray || state.currentPhase === BossPhase.Lash) {
|
||||||
|
events.push({
|
||||||
|
type: 'boss_attack',
|
||||||
|
phase: state.currentPhase,
|
||||||
|
cycleCount: state.cycleCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return events;
|
||||||
|
}
|
||||||
136
src/boss/arena.ts
Normal file
136
src/boss/arena.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* Boss Arena Generator — creates a circular arena for Archon encounters
|
||||||
|
*
|
||||||
|
* The arena is a special room with:
|
||||||
|
* - Circular walkable area surrounded by crystal walls
|
||||||
|
* - Acid pools near the edges (hazards)
|
||||||
|
* - 4 mineral deposits at cardinal positions (for ammo)
|
||||||
|
* - Boss spawns at center, player enters from south
|
||||||
|
*
|
||||||
|
* Uses the same tile index system as the main world generator.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BossData, ArenaData } from './types';
|
||||||
|
import type { BiomeData } from '../world/types';
|
||||||
|
|
||||||
|
/** Tile indices matching biomes.json tile order */
|
||||||
|
const TILE = {
|
||||||
|
GROUND: 0,
|
||||||
|
SCORCHED_EARTH: 1,
|
||||||
|
ACID_SHALLOW: 2,
|
||||||
|
ACID_DEEP: 3,
|
||||||
|
CRYSTAL_FORMATION: 4,
|
||||||
|
GEYSER: 5,
|
||||||
|
MINERAL_VEIN: 6,
|
||||||
|
BEDROCK: 7,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a circular boss arena.
|
||||||
|
*
|
||||||
|
* @param boss - Boss data (determines arena size)
|
||||||
|
* @param biome - Biome data (for tile size)
|
||||||
|
* @returns Arena data with grid, spawn positions, and resource locations
|
||||||
|
*/
|
||||||
|
export function generateArena(boss: BossData, biome: BiomeData): ArenaData {
|
||||||
|
const radius = boss.arenaRadius;
|
||||||
|
const diameter = radius * 2 + 1;
|
||||||
|
const centerTile = radius;
|
||||||
|
const tileSize = biome.tileSize;
|
||||||
|
|
||||||
|
// Initialize grid with bedrock (impassable)
|
||||||
|
const grid: number[][] = [];
|
||||||
|
for (let y = 0; y < diameter; y++) {
|
||||||
|
grid[y] = [];
|
||||||
|
for (let x = 0; x < diameter; x++) {
|
||||||
|
grid[y][x] = TILE.BEDROCK;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourcePositions: { x: number; y: number }[] = [];
|
||||||
|
|
||||||
|
// Carve circular arena
|
||||||
|
for (let y = 0; y < diameter; y++) {
|
||||||
|
for (let x = 0; x < diameter; x++) {
|
||||||
|
const dx = x - centerTile;
|
||||||
|
const dy = y - centerTile;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (dist <= radius - 1) {
|
||||||
|
// Inner arena: walkable ground
|
||||||
|
grid[y][x] = TILE.GROUND;
|
||||||
|
|
||||||
|
// Acid pools ring at 60-75% of radius
|
||||||
|
if (dist > radius * 0.6 && dist <= radius * 0.75) {
|
||||||
|
// Place acid in a pattern (not solid ring — alternating)
|
||||||
|
const angle = Math.atan2(dy, dx);
|
||||||
|
const sector = Math.floor((angle + Math.PI) / (Math.PI / 4));
|
||||||
|
if (sector % 2 === 0) {
|
||||||
|
grid[y][x] = TILE.ACID_SHALLOW;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scorched earth ring at 75-90% of radius
|
||||||
|
if (dist > radius * 0.75 && dist <= radius * 0.9) {
|
||||||
|
grid[y][x] = TILE.SCORCHED_EARTH;
|
||||||
|
}
|
||||||
|
} else if (dist <= radius) {
|
||||||
|
// Crystal wall border
|
||||||
|
grid[y][x] = TILE.CRYSTAL_FORMATION;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Place 4 mineral deposits at cardinal positions (40% of radius from center)
|
||||||
|
const mineralDist = Math.round(radius * 0.4);
|
||||||
|
const cardinalOffsets = [
|
||||||
|
{ dx: 0, dy: -mineralDist }, // North
|
||||||
|
{ dx: mineralDist, dy: 0 }, // East
|
||||||
|
{ dx: 0, dy: mineralDist }, // South (near player spawn)
|
||||||
|
{ dx: -mineralDist, dy: 0 }, // West
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const offset of cardinalOffsets) {
|
||||||
|
const mx = centerTile + offset.dx;
|
||||||
|
const my = centerTile + offset.dy;
|
||||||
|
if (mx >= 0 && mx < diameter && my >= 0 && my < diameter) {
|
||||||
|
grid[my][mx] = TILE.MINERAL_VEIN;
|
||||||
|
resourcePositions.push({
|
||||||
|
x: mx * tileSize + tileSize / 2,
|
||||||
|
y: my * tileSize + tileSize / 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boss spawns at center
|
||||||
|
const bossSpawnX = centerTile * tileSize + tileSize / 2;
|
||||||
|
const bossSpawnY = centerTile * tileSize + tileSize / 2;
|
||||||
|
|
||||||
|
// Player spawns at south edge (80% of radius from center)
|
||||||
|
const playerSpawnX = centerTile * tileSize + tileSize / 2;
|
||||||
|
const playerSpawnY = (centerTile + Math.round(radius * 0.8)) * tileSize + tileSize / 2;
|
||||||
|
|
||||||
|
return {
|
||||||
|
grid,
|
||||||
|
width: diameter,
|
||||||
|
height: diameter,
|
||||||
|
bossSpawnX,
|
||||||
|
bossSpawnY,
|
||||||
|
playerSpawnX,
|
||||||
|
playerSpawnY,
|
||||||
|
resourcePositions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a set of walkable tile indices for the arena.
|
||||||
|
* Matches the pattern used in the main world's collision system.
|
||||||
|
*/
|
||||||
|
export function buildArenaWalkableSet(): Set<number> {
|
||||||
|
return new Set([
|
||||||
|
TILE.GROUND,
|
||||||
|
TILE.SCORCHED_EARTH,
|
||||||
|
TILE.MINERAL_VEIN,
|
||||||
|
TILE.GEYSER,
|
||||||
|
]);
|
||||||
|
}
|
||||||
59
src/boss/factory.ts
Normal file
59
src/boss/factory.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Boss Entity Factory — creates boss entities in the ECS world
|
||||||
|
*
|
||||||
|
* The boss gets: Position, Velocity, Health, SpriteRef, Boss components.
|
||||||
|
* Rich state (phase, cycle, stacks) is stored in BossState, not ECS.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { addEntity, addComponent } from 'bitecs';
|
||||||
|
import type { World } from '../ecs/world';
|
||||||
|
import {
|
||||||
|
Position,
|
||||||
|
Velocity,
|
||||||
|
Health,
|
||||||
|
SpriteRef,
|
||||||
|
Boss,
|
||||||
|
} from '../ecs/components';
|
||||||
|
import type { BossData } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a boss entity at the given position.
|
||||||
|
*
|
||||||
|
* @returns Entity ID
|
||||||
|
*/
|
||||||
|
export function createBossEntity(
|
||||||
|
world: World,
|
||||||
|
boss: BossData,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
): number {
|
||||||
|
const eid = addEntity(world);
|
||||||
|
|
||||||
|
// Position
|
||||||
|
addComponent(world, eid, Position);
|
||||||
|
Position.x[eid] = x;
|
||||||
|
Position.y[eid] = y;
|
||||||
|
|
||||||
|
// Velocity (boss controls its own movement via AI)
|
||||||
|
addComponent(world, eid, Velocity);
|
||||||
|
Velocity.vx[eid] = 0;
|
||||||
|
Velocity.vy[eid] = 0;
|
||||||
|
|
||||||
|
// Health
|
||||||
|
addComponent(world, eid, Health);
|
||||||
|
Health.current[eid] = boss.health;
|
||||||
|
Health.max[eid] = boss.health;
|
||||||
|
|
||||||
|
// Visual
|
||||||
|
addComponent(world, eid, SpriteRef);
|
||||||
|
SpriteRef.color[eid] = parseInt(boss.color.replace('#', ''), 16);
|
||||||
|
SpriteRef.radius[eid] = boss.radius;
|
||||||
|
|
||||||
|
// Boss tag
|
||||||
|
addComponent(world, eid, Boss);
|
||||||
|
Boss.dataIndex[eid] = 0; // Index in bosses.json array (only Ouroboros for now)
|
||||||
|
Boss.phase[eid] = 0; // BossPhase.Coil
|
||||||
|
Boss.cycleCount[eid] = 0;
|
||||||
|
|
||||||
|
return eid;
|
||||||
|
}
|
||||||
19
src/boss/index.ts
Normal file
19
src/boss/index.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Boss module — Archon encounters
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { BossPhase, VictoryMethod } from './types';
|
||||||
|
export type {
|
||||||
|
BossData,
|
||||||
|
BossState,
|
||||||
|
BossDamageResult,
|
||||||
|
BossPhaseEvent,
|
||||||
|
ArenaData,
|
||||||
|
BossReward,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export { createBossState, updateBossPhase, getEffectiveArmor, getEffectiveRegen, isVulnerable, getEffectivePhaseDuration } from './ai';
|
||||||
|
export { applyBossDamage, isBossDefeated } from './victory';
|
||||||
|
export { generateArena, buildArenaWalkableSet } from './arena';
|
||||||
|
export { calculateBossReward, applyBossReward } from './reward';
|
||||||
|
export { createBossEntity } from './factory';
|
||||||
86
src/boss/reward.ts
Normal file
86
src/boss/reward.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Boss Reward System — Archont's Memory and spore rewards
|
||||||
|
*
|
||||||
|
* Defeating a boss grants:
|
||||||
|
* 1. Spores (currency for meta-progression)
|
||||||
|
* 2. Archont's Memory — a lore entry added to the Codex
|
||||||
|
* 3. Victory flavor based on the method used
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { VictoryMethod, type BossData, type BossState, type BossReward } from './types';
|
||||||
|
import type { MetaState, CodexEntry } from '../run/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the reward for defeating a boss.
|
||||||
|
*
|
||||||
|
* @param state - Boss state at time of defeat
|
||||||
|
* @param boss - Boss configuration data
|
||||||
|
* @returns Reward with spores, lore, and flavor
|
||||||
|
*/
|
||||||
|
export function calculateBossReward(
|
||||||
|
state: BossState,
|
||||||
|
boss: BossData,
|
||||||
|
): BossReward {
|
||||||
|
if (!state.defeated || !state.victoryMethod) {
|
||||||
|
return {
|
||||||
|
spores: 0,
|
||||||
|
loreId: boss.id,
|
||||||
|
loreText: boss.loreEntry,
|
||||||
|
loreTextRu: boss.loreEntryRu,
|
||||||
|
victoryMethod: VictoryMethod.Direct,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base reward + bonus for elegant victory methods
|
||||||
|
let spores = boss.sporeReward;
|
||||||
|
|
||||||
|
switch (state.victoryMethod) {
|
||||||
|
case VictoryMethod.Chemical:
|
||||||
|
// Chemical victory: 50% bonus (requires crafting NaOH + timing)
|
||||||
|
spores = Math.round(spores * 1.5);
|
||||||
|
break;
|
||||||
|
case VictoryMethod.Catalytic:
|
||||||
|
// Catalytic victory: 100% bonus (requires observation + Hg)
|
||||||
|
spores = Math.round(spores * 2.0);
|
||||||
|
break;
|
||||||
|
case VictoryMethod.Direct:
|
||||||
|
// Direct victory: base reward
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
spores,
|
||||||
|
loreId: boss.id,
|
||||||
|
loreText: boss.loreEntry,
|
||||||
|
loreTextRu: boss.loreEntryRu,
|
||||||
|
victoryMethod: state.victoryMethod,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply boss defeat to meta state: add codex entry and spores.
|
||||||
|
*
|
||||||
|
* @param meta - Mutable meta state
|
||||||
|
* @param reward - Boss reward to apply
|
||||||
|
* @param runId - Current run ID
|
||||||
|
*/
|
||||||
|
export function applyBossReward(
|
||||||
|
meta: MetaState,
|
||||||
|
reward: BossReward,
|
||||||
|
runId: number,
|
||||||
|
): void {
|
||||||
|
// Add spores
|
||||||
|
meta.spores += reward.spores;
|
||||||
|
|
||||||
|
// Add boss lore to codex (skip if already exists)
|
||||||
|
const key = `boss:${reward.loreId}`;
|
||||||
|
const exists = meta.codex.some(e => `${e.type}:${e.id}` === key);
|
||||||
|
if (!exists) {
|
||||||
|
const entry: CodexEntry = {
|
||||||
|
id: reward.loreId,
|
||||||
|
type: 'boss' as CodexEntry['type'],
|
||||||
|
discoveredOnRun: runId,
|
||||||
|
};
|
||||||
|
meta.codex.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
202
src/boss/types.ts
Normal file
202
src/boss/types.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* Boss Fight Types — Archon encounters
|
||||||
|
*
|
||||||
|
* Ouroboros: The Archon of Cycles — a serpent consuming its own tail.
|
||||||
|
* The fight is a cyclical pattern-recognition puzzle with 3 victory paths:
|
||||||
|
* 1. Chemical — NaOH neutralization during acid phase (real acid-base chemistry)
|
||||||
|
* 2. Direct — projectile damage during vulnerability window
|
||||||
|
* 3. Catalytic — Hg catalyst poisoning (real: mercury poisons catalytic sites)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── Boss Phases ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Cyclical boss attack phases (repeat with escalation) */
|
||||||
|
export enum BossPhase {
|
||||||
|
/** Arena constricts — boss coils, safe zone shrinks */
|
||||||
|
Coil = 0,
|
||||||
|
/** Acid projectile spray — rotating pattern */
|
||||||
|
Spray = 1,
|
||||||
|
/** Tail sweep — sector damage arcs */
|
||||||
|
Lash = 2,
|
||||||
|
/** Self-consumption pause — vulnerability window */
|
||||||
|
Digest = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BOSS_PHASE_COUNT = 4;
|
||||||
|
|
||||||
|
export const BOSS_PHASE_NAMES: Record<BossPhase, string> = {
|
||||||
|
[BossPhase.Coil]: 'Coil',
|
||||||
|
[BossPhase.Spray]: 'Acid Spray',
|
||||||
|
[BossPhase.Lash]: 'Tail Lash',
|
||||||
|
[BossPhase.Digest]: 'Digest',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BOSS_PHASE_NAMES_RU: Record<BossPhase, string> = {
|
||||||
|
[BossPhase.Coil]: 'Сжатие',
|
||||||
|
[BossPhase.Spray]: 'Кислотный Залп',
|
||||||
|
[BossPhase.Lash]: 'Удар Хвостом',
|
||||||
|
[BossPhase.Digest]: 'Переваривание',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Victory Methods ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** How the boss was defeated (determines reward flavor) */
|
||||||
|
export enum VictoryMethod {
|
||||||
|
/** NaOH acid-base neutralization during Spray phase */
|
||||||
|
Chemical = 'chemical',
|
||||||
|
/** Brute force projectile damage during Digest */
|
||||||
|
Direct = 'direct',
|
||||||
|
/** Hg catalyst poisoning — disrupts self-catalysis */
|
||||||
|
Catalytic = 'catalytic',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Boss Data (from JSON) ──────────────────────────────────────
|
||||||
|
|
||||||
|
/** Boss configuration loaded from bosses.json */
|
||||||
|
export interface BossData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
nameRu: string;
|
||||||
|
description: string;
|
||||||
|
descriptionRu: string;
|
||||||
|
|
||||||
|
/** Hex color string for rendering */
|
||||||
|
color: string;
|
||||||
|
/** Base radius in pixels */
|
||||||
|
radius: number;
|
||||||
|
|
||||||
|
/** Maximum health points */
|
||||||
|
health: number;
|
||||||
|
/** Damage reduction 0–1 during non-vulnerable phases */
|
||||||
|
armor: number;
|
||||||
|
/** Damage reduction 0–1 during vulnerable phases */
|
||||||
|
armorVulnerable: number;
|
||||||
|
/** HP regeneration per second (self-catalysis) */
|
||||||
|
regenPerSecond: number;
|
||||||
|
/** Base attack damage to player */
|
||||||
|
damage: number;
|
||||||
|
|
||||||
|
/** Phase durations in ms: [Coil, Spray, Lash, Digest] */
|
||||||
|
phaseDurations: [number, number, number, number];
|
||||||
|
/** Duration multiplier per completed cycle (< 1 = faster) */
|
||||||
|
phaseSpeedupPerCycle: number;
|
||||||
|
/** Difficulty caps after this many cycles */
|
||||||
|
maxCycles: number;
|
||||||
|
|
||||||
|
/** BossPhase values where boss takes full damage */
|
||||||
|
vulnerablePhases: number[];
|
||||||
|
/** Compound ID that deals bonus chemical damage */
|
||||||
|
chemicalWeakness: string;
|
||||||
|
/** Damage multiplier for chemical weakness */
|
||||||
|
chemicalDamageMultiplier: number;
|
||||||
|
/** Phases where chemical weakness applies (e.g. Spray for NaOH) */
|
||||||
|
chemicalEffectivePhases: number[];
|
||||||
|
/** Element symbol that acts as catalyst poison */
|
||||||
|
catalystPoison: string;
|
||||||
|
/** Regen reduction per catalyst stack (HP/s) */
|
||||||
|
catalystRegenReduction: number;
|
||||||
|
/** Armor reduction per catalyst stack (absolute) */
|
||||||
|
catalystArmorReduction: number;
|
||||||
|
/** Maximum catalyst stacks */
|
||||||
|
maxCatalystStacks: number;
|
||||||
|
|
||||||
|
/** Arena radius in tiles */
|
||||||
|
arenaRadius: number;
|
||||||
|
|
||||||
|
/** Lore text revealed on victory (English) */
|
||||||
|
loreEntry: string;
|
||||||
|
/** Lore text revealed on victory (Russian) */
|
||||||
|
loreEntryRu: string;
|
||||||
|
|
||||||
|
/** Spore reward for defeating the boss */
|
||||||
|
sporeReward: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Runtime Boss State ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Runtime state of the boss fight (stored in scene, not ECS) */
|
||||||
|
export interface BossState {
|
||||||
|
/** Boss data ID */
|
||||||
|
bossId: string;
|
||||||
|
/** Current health */
|
||||||
|
health: number;
|
||||||
|
/** Maximum health */
|
||||||
|
maxHealth: number;
|
||||||
|
/** Current attack phase */
|
||||||
|
currentPhase: BossPhase;
|
||||||
|
/** Time remaining in current phase (ms) */
|
||||||
|
phaseTimer: number;
|
||||||
|
/** Number of completed full cycles (Coil→Spray→Lash→Digest) */
|
||||||
|
cycleCount: number;
|
||||||
|
/** Number of catalyst poison (Hg) applications */
|
||||||
|
catalystStacks: number;
|
||||||
|
/** Whether the boss has been defeated */
|
||||||
|
defeated: boolean;
|
||||||
|
/** How the boss was defeated (null if not yet) */
|
||||||
|
victoryMethod: VictoryMethod | null;
|
||||||
|
/** Running totals by damage type */
|
||||||
|
totalDamageDealt: number;
|
||||||
|
chemicalDamageDealt: number;
|
||||||
|
directDamageDealt: number;
|
||||||
|
catalystDamageDealt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Damage Result ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Result of applying damage to the boss */
|
||||||
|
export interface BossDamageResult {
|
||||||
|
/** Actual damage dealt after armor */
|
||||||
|
damageDealt: number;
|
||||||
|
/** Type of damage */
|
||||||
|
damageType: VictoryMethod;
|
||||||
|
/** Whether this was a killing blow */
|
||||||
|
killingBlow: boolean;
|
||||||
|
/** Whether a catalyst stack was applied */
|
||||||
|
catalystApplied: boolean;
|
||||||
|
/** Feedback message for player */
|
||||||
|
message: string;
|
||||||
|
messageRu: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Boss Phase Event ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Events emitted during boss phase updates */
|
||||||
|
export interface BossPhaseEvent {
|
||||||
|
type: 'phase_change' | 'cycle_complete' | 'boss_attack';
|
||||||
|
phase: BossPhase;
|
||||||
|
cycleCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Arena Data ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Generated arena tile layout */
|
||||||
|
export interface ArenaData {
|
||||||
|
/** Tile grid (same format as WorldData.grid) */
|
||||||
|
grid: number[][];
|
||||||
|
/** Arena width in tiles */
|
||||||
|
width: number;
|
||||||
|
/** Arena height in tiles */
|
||||||
|
height: number;
|
||||||
|
/** Boss spawn position in pixels */
|
||||||
|
bossSpawnX: number;
|
||||||
|
bossSpawnY: number;
|
||||||
|
/** Player spawn position in pixels */
|
||||||
|
playerSpawnX: number;
|
||||||
|
playerSpawnY: number;
|
||||||
|
/** Resource deposit positions in pixels */
|
||||||
|
resourcePositions: { x: number; y: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Boss Reward ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Reward for defeating the boss */
|
||||||
|
export interface BossReward {
|
||||||
|
/** Spores earned */
|
||||||
|
spores: number;
|
||||||
|
/** Lore text for Codex */
|
||||||
|
loreId: string;
|
||||||
|
loreText: string;
|
||||||
|
loreTextRu: string;
|
||||||
|
/** Victory method determines flavor */
|
||||||
|
victoryMethod: VictoryMethod;
|
||||||
|
}
|
||||||
178
src/boss/victory.ts
Normal file
178
src/boss/victory.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* Boss Victory Conditions — 3 paths to defeating an Archon
|
||||||
|
*
|
||||||
|
* 1. Chemical: NaOH during Spray phase (acid-base neutralization)
|
||||||
|
* 2. Direct: Any projectile during Digest vulnerability window
|
||||||
|
* 3. Catalytic: Hg poison stacks (disrupts self-catalysis)
|
||||||
|
*
|
||||||
|
* Real science behind each method:
|
||||||
|
* - NaOH + HCl → NaCl + H₂O (neutralization of Ouroboros acid)
|
||||||
|
* - Mercury poisons catalytic sites (real catalysis chemistry)
|
||||||
|
* - Direct damage exploits the brief self-consumption pause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { VictoryMethod, type BossData, type BossState, type BossDamageResult } from './types';
|
||||||
|
import { getEffectiveArmor, isVulnerable } from './ai';
|
||||||
|
|
||||||
|
/** Base damage from a projectile hit */
|
||||||
|
const PROJECTILE_BASE_DAMAGE = 15;
|
||||||
|
|
||||||
|
/** Damage dealt by catalyst poison application */
|
||||||
|
const CATALYST_DAMAGE = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply damage to the boss from a projectile hit.
|
||||||
|
* Determines damage type based on the item used and current boss phase.
|
||||||
|
*
|
||||||
|
* @param state - Mutable boss state
|
||||||
|
* @param boss - Immutable boss data
|
||||||
|
* @param itemId - The element/compound symbol used (e.g. "NaOH", "Hg", "Fe")
|
||||||
|
* @returns Damage result with feedback
|
||||||
|
*/
|
||||||
|
export function applyBossDamage(
|
||||||
|
state: BossState,
|
||||||
|
boss: BossData,
|
||||||
|
itemId: string,
|
||||||
|
): BossDamageResult {
|
||||||
|
if (state.defeated) {
|
||||||
|
return {
|
||||||
|
damageDealt: 0,
|
||||||
|
damageType: VictoryMethod.Direct,
|
||||||
|
killingBlow: false,
|
||||||
|
catalystApplied: false,
|
||||||
|
message: 'Boss already defeated',
|
||||||
|
messageRu: 'Босс уже побеждён',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const armor = getEffectiveArmor(state, boss);
|
||||||
|
|
||||||
|
// Path 3: Catalyst poison (Hg)
|
||||||
|
if (itemId === boss.catalystPoison) {
|
||||||
|
return applyCatalystDamage(state, boss, armor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path 1: Chemical weakness (NaOH during Spray)
|
||||||
|
if (
|
||||||
|
itemId === boss.chemicalWeakness &&
|
||||||
|
boss.chemicalEffectivePhases.includes(state.currentPhase)
|
||||||
|
) {
|
||||||
|
return applyChemicalDamage(state, boss, armor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path 2: Direct damage (any projectile during vulnerable phase)
|
||||||
|
if (isVulnerable(state, boss)) {
|
||||||
|
return applyDirectDamage(state, boss, armor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-vulnerable phase, non-special item → heavily reduced damage
|
||||||
|
const reducedDamage = Math.max(1, Math.round(PROJECTILE_BASE_DAMAGE * (1 - armor)));
|
||||||
|
return dealDamage(state, reducedDamage, VictoryMethod.Direct, false, {
|
||||||
|
message: `Ouroboros absorbs the blow (${reducedDamage} dmg)`,
|
||||||
|
messageRu: `Уроборос поглощает удар (${reducedDamage} урона)`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply chemical damage (NaOH neutralization) */
|
||||||
|
function applyChemicalDamage(
|
||||||
|
state: BossState,
|
||||||
|
boss: BossData,
|
||||||
|
armor: number,
|
||||||
|
): BossDamageResult {
|
||||||
|
const rawDamage = PROJECTILE_BASE_DAMAGE * boss.chemicalDamageMultiplier;
|
||||||
|
const damage = Math.max(1, Math.round(rawDamage * (1 - armor)));
|
||||||
|
|
||||||
|
return dealDamage(state, damage, VictoryMethod.Chemical, false, {
|
||||||
|
message: `Acid neutralized! NaOH + acid → salt + water (${damage} dmg)`,
|
||||||
|
messageRu: `Кислота нейтрализована! NaOH + кислота → соль + вода (${damage} урона)`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply direct projectile damage during vulnerability */
|
||||||
|
function applyDirectDamage(
|
||||||
|
state: BossState,
|
||||||
|
boss: BossData,
|
||||||
|
armor: number,
|
||||||
|
): BossDamageResult {
|
||||||
|
const damage = Math.max(1, Math.round(PROJECTILE_BASE_DAMAGE * (1 - armor)));
|
||||||
|
|
||||||
|
return dealDamage(state, damage, VictoryMethod.Direct, false, {
|
||||||
|
message: `Hit during digestion! (${damage} dmg)`,
|
||||||
|
messageRu: `Попадание во время переваривания! (${damage} урона)`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply catalyst poison (Hg) */
|
||||||
|
function applyCatalystDamage(
|
||||||
|
state: BossState,
|
||||||
|
boss: BossData,
|
||||||
|
armor: number,
|
||||||
|
): BossDamageResult {
|
||||||
|
let catalystApplied = false;
|
||||||
|
let message: string;
|
||||||
|
let messageRu: string;
|
||||||
|
|
||||||
|
if (state.catalystStacks < boss.maxCatalystStacks) {
|
||||||
|
state.catalystStacks += 1;
|
||||||
|
catalystApplied = true;
|
||||||
|
message = `Mercury poisons catalytic site! Stack ${state.catalystStacks}/${boss.maxCatalystStacks} — regen and armor permanently reduced`;
|
||||||
|
messageRu = `Ртуть отравляет каталитический центр! Стак ${state.catalystStacks}/${boss.maxCatalystStacks} — регенерация и броня снижены навсегда`;
|
||||||
|
} else {
|
||||||
|
message = `Catalyst fully poisoned (${CATALYST_DAMAGE} dmg)`;
|
||||||
|
messageRu = `Катализатор полностью отравлен (${CATALYST_DAMAGE} урона)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const damage = Math.max(1, Math.round(CATALYST_DAMAGE * (1 - armor)));
|
||||||
|
|
||||||
|
return dealDamage(state, damage, VictoryMethod.Catalytic, catalystApplied, {
|
||||||
|
message,
|
||||||
|
messageRu,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Common damage application logic */
|
||||||
|
function dealDamage(
|
||||||
|
state: BossState,
|
||||||
|
damage: number,
|
||||||
|
damageType: VictoryMethod,
|
||||||
|
catalystApplied: boolean,
|
||||||
|
messages: { message: string; messageRu: string },
|
||||||
|
): BossDamageResult {
|
||||||
|
state.health = Math.max(0, state.health - damage);
|
||||||
|
state.totalDamageDealt += damage;
|
||||||
|
|
||||||
|
// Track damage by type
|
||||||
|
switch (damageType) {
|
||||||
|
case VictoryMethod.Chemical:
|
||||||
|
state.chemicalDamageDealt += damage;
|
||||||
|
break;
|
||||||
|
case VictoryMethod.Direct:
|
||||||
|
state.directDamageDealt += damage;
|
||||||
|
break;
|
||||||
|
case VictoryMethod.Catalytic:
|
||||||
|
state.catalystDamageDealt += damage;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for killing blow
|
||||||
|
const killingBlow = state.health <= 0;
|
||||||
|
if (killingBlow) {
|
||||||
|
state.defeated = true;
|
||||||
|
state.victoryMethod = damageType;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
damageDealt: damage,
|
||||||
|
damageType,
|
||||||
|
killingBlow,
|
||||||
|
catalystApplied,
|
||||||
|
...messages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the boss is defeated.
|
||||||
|
*/
|
||||||
|
export function isBossDefeated(state: BossState): boolean {
|
||||||
|
return state.defeated;
|
||||||
|
}
|
||||||
@@ -8,7 +8,8 @@ export type ElementCategory =
|
|||||||
| 'metalloid'
|
| 'metalloid'
|
||||||
| 'nonmetal'
|
| 'nonmetal'
|
||||||
| 'halogen'
|
| 'halogen'
|
||||||
| 'noble-gas';
|
| 'noble-gas'
|
||||||
|
| 'actinide';
|
||||||
|
|
||||||
export type MatterState = 'solid' | 'liquid' | 'gas';
|
export type MatterState = 'solid' | 'liquid' | 'gas';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
import { BootScene } from './scenes/BootScene';
|
import { BootScene } from './scenes/BootScene';
|
||||||
|
import { CradleScene } from './scenes/CradleScene';
|
||||||
import { GameScene } from './scenes/GameScene';
|
import { GameScene } from './scenes/GameScene';
|
||||||
|
import { UIScene } from './scenes/UIScene';
|
||||||
|
import { DeathScene } from './scenes/DeathScene';
|
||||||
|
import { FractalScene } from './scenes/FractalScene';
|
||||||
|
import { BossArenaScene } from './scenes/BossArenaScene';
|
||||||
|
import { RenewalScene } from './scenes/RenewalScene';
|
||||||
|
|
||||||
export const GAME_WIDTH = 1280;
|
export const GAME_WIDTH = 1280;
|
||||||
export const GAME_HEIGHT = 720;
|
export const GAME_HEIGHT = 720;
|
||||||
@@ -11,7 +17,7 @@ export const gameConfig: Phaser.Types.Core.GameConfig = {
|
|||||||
height: GAME_HEIGHT,
|
height: GAME_HEIGHT,
|
||||||
backgroundColor: '#0a0a0a',
|
backgroundColor: '#0a0a0a',
|
||||||
parent: document.body,
|
parent: document.body,
|
||||||
scene: [BootScene, GameScene],
|
scene: [BootScene, CradleScene, GameScene, UIScene, DeathScene, FractalScene, RenewalScene, BossArenaScene],
|
||||||
physics: {
|
physics: {
|
||||||
default: 'arcade',
|
default: 'arcade',
|
||||||
arcade: {
|
arcade: {
|
||||||
|
|||||||
350
src/creatures/ai.ts
Normal file
350
src/creatures/ai.ts
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
/**
|
||||||
|
* Creature AI System — Finite State Machine
|
||||||
|
*
|
||||||
|
* States: Idle → Wander → Feed → Flee → Attack
|
||||||
|
* Transitions based on hunger, threats, territory.
|
||||||
|
*
|
||||||
|
* Pure function system — reads/writes ECS components only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { query } from 'bitecs';
|
||||||
|
import type { World } from '../ecs/world';
|
||||||
|
import {
|
||||||
|
Position,
|
||||||
|
Velocity,
|
||||||
|
Health,
|
||||||
|
Creature,
|
||||||
|
AI,
|
||||||
|
Metabolism,
|
||||||
|
LifeCycle,
|
||||||
|
PlayerTag,
|
||||||
|
Resource,
|
||||||
|
} from '../ecs/components';
|
||||||
|
import { AIState, LifeStage, SpeciesId } from './types';
|
||||||
|
import type { SpeciesData } from './types';
|
||||||
|
|
||||||
|
/** Seeded pseudo-random for AI decisions (deterministic per frame) */
|
||||||
|
function aiRandom(eid: number, tick: number): number {
|
||||||
|
let h = ((eid * 2654435761) ^ (tick * 2246822507)) >>> 0;
|
||||||
|
h = Math.imul(h ^ (h >>> 16), 0x45d9f3b);
|
||||||
|
h = Math.imul(h ^ (h >>> 16), 0x45d9f3b);
|
||||||
|
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calculate squared distance between two entities */
|
||||||
|
function distSq(ax: number, ay: number, bx: number, by: number): number {
|
||||||
|
const dx = ax - bx;
|
||||||
|
const dy = ay - by;
|
||||||
|
return dx * dx + dy * dy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set velocity toward a target position at given speed */
|
||||||
|
function moveToward(
|
||||||
|
eid: number,
|
||||||
|
targetX: number,
|
||||||
|
targetY: number,
|
||||||
|
speed: number,
|
||||||
|
): void {
|
||||||
|
const dx = targetX - Position.x[eid];
|
||||||
|
const dy = targetY - Position.y[eid];
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (dist < 1) {
|
||||||
|
Velocity.vx[eid] = 0;
|
||||||
|
Velocity.vy[eid] = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Velocity.vx[eid] = (dx / dist) * speed;
|
||||||
|
Velocity.vy[eid] = (dy / dist) * speed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set velocity away from a position at given speed */
|
||||||
|
function moveAway(
|
||||||
|
eid: number,
|
||||||
|
fromX: number,
|
||||||
|
fromY: number,
|
||||||
|
speed: number,
|
||||||
|
): void {
|
||||||
|
const dx = Position.x[eid] - fromX;
|
||||||
|
const dy = Position.y[eid] - fromY;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (dist < 1) {
|
||||||
|
// Random direction when exactly on top
|
||||||
|
Velocity.vx[eid] = speed;
|
||||||
|
Velocity.vy[eid] = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Velocity.vx[eid] = (dx / dist) * speed;
|
||||||
|
Velocity.vy[eid] = (dy / dist) * speed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the nearest entity matching a predicate.
|
||||||
|
* @returns [eid, distanceSquared] or [-1, Infinity] if none found
|
||||||
|
*/
|
||||||
|
function findNearest(
|
||||||
|
world: World,
|
||||||
|
fromX: number,
|
||||||
|
fromY: number,
|
||||||
|
candidates: number[],
|
||||||
|
maxDistSq: number,
|
||||||
|
): [number, number] {
|
||||||
|
let bestEid = -1;
|
||||||
|
let bestDist = Infinity;
|
||||||
|
|
||||||
|
for (const eid of candidates) {
|
||||||
|
const d = distSq(fromX, fromY, Position.x[eid], Position.y[eid]);
|
||||||
|
if (d < bestDist && d <= maxDistSq) {
|
||||||
|
bestDist = d;
|
||||||
|
bestEid = eid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [bestEid, bestDist];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI System — runs FSM transitions and actions for all creatures.
|
||||||
|
*
|
||||||
|
* @param speciesLookup - map from SpeciesId to species stats
|
||||||
|
* @param tick - current game tick (for deterministic randomness)
|
||||||
|
* @param aggroMultiplier - multiplier on flee/aggression radius (default 1.0, e.g. Naturalist bonus)
|
||||||
|
*/
|
||||||
|
export function aiSystem(
|
||||||
|
world: World,
|
||||||
|
deltaMs: number,
|
||||||
|
speciesLookup: Map<number, SpeciesData>,
|
||||||
|
tick: number,
|
||||||
|
aggroMultiplier = 1.0,
|
||||||
|
): void {
|
||||||
|
const dt = deltaMs / 1000;
|
||||||
|
const creatures = query(world, [Creature, AI, Position, Velocity, Metabolism, LifeCycle]);
|
||||||
|
|
||||||
|
// Gather player positions for flee calculations
|
||||||
|
const players = query(world, [PlayerTag, Position]);
|
||||||
|
const playerPositions = players.map(eid => ({
|
||||||
|
x: Position.x[eid],
|
||||||
|
y: Position.y[eid],
|
||||||
|
eid,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Gather resource positions for feeding
|
||||||
|
const resources = query(world, [Resource, Position]);
|
||||||
|
|
||||||
|
for (const eid of creatures) {
|
||||||
|
// Eggs don't act
|
||||||
|
if (LifeCycle.stage[eid] === LifeStage.Egg) {
|
||||||
|
Velocity.vx[eid] = 0;
|
||||||
|
Velocity.vy[eid] = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const species = speciesLookup.get(Creature.speciesId[eid]);
|
||||||
|
if (!species) continue;
|
||||||
|
|
||||||
|
const speed = LifeCycle.stage[eid] === LifeStage.Youth
|
||||||
|
? species.speed * 0.7
|
||||||
|
: LifeCycle.stage[eid] === LifeStage.Aging
|
||||||
|
? species.speed * 0.5
|
||||||
|
: species.speed;
|
||||||
|
|
||||||
|
// Reduce attack cooldown
|
||||||
|
if (AI.attackCooldown[eid] > 0) {
|
||||||
|
AI.attackCooldown[eid] -= deltaMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reduce state timer
|
||||||
|
AI.stateTimer[eid] -= deltaMs;
|
||||||
|
|
||||||
|
// ─── Check for threats (player proximity) ───
|
||||||
|
let nearestThreatDist = Infinity;
|
||||||
|
let nearestThreatX = 0;
|
||||||
|
let nearestThreatY = 0;
|
||||||
|
|
||||||
|
for (const p of playerPositions) {
|
||||||
|
const d = distSq(Position.x[eid], Position.y[eid], p.x, p.y);
|
||||||
|
if (d < nearestThreatDist) {
|
||||||
|
nearestThreatDist = d;
|
||||||
|
nearestThreatX = p.x;
|
||||||
|
nearestThreatY = p.y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveFleeRadius = species.fleeRadius * aggroMultiplier;
|
||||||
|
const fleeRadiusSq = effectiveFleeRadius * effectiveFleeRadius;
|
||||||
|
const shouldFlee = nearestThreatDist < fleeRadiusSq
|
||||||
|
&& species.speciesId !== SpeciesId.Reagent; // Reagents don't flee
|
||||||
|
|
||||||
|
// ─── Check hunger ───
|
||||||
|
const hungerFraction = Metabolism.energy[eid] / Metabolism.energyMax[eid];
|
||||||
|
const isHungry = hungerFraction < Metabolism.hungerThreshold[eid];
|
||||||
|
|
||||||
|
// ─── Check for prey (Reagents only) ───
|
||||||
|
const effectiveAggrRadius = species.aggressionRadius * aggroMultiplier;
|
||||||
|
const aggrRadiusSq = effectiveAggrRadius * effectiveAggrRadius;
|
||||||
|
let nearestPreyEid = -1;
|
||||||
|
let nearestPreyDist = Infinity;
|
||||||
|
|
||||||
|
if (species.speciesId === SpeciesId.Reagent) {
|
||||||
|
// Reagents hunt other creatures (non-Reagent)
|
||||||
|
for (const other of creatures) {
|
||||||
|
if (other === eid) continue;
|
||||||
|
if (Creature.speciesId[other] === SpeciesId.Reagent) continue;
|
||||||
|
if (LifeCycle.stage[other] === LifeStage.Egg) continue;
|
||||||
|
|
||||||
|
const d = distSq(Position.x[eid], Position.y[eid], Position.x[other], Position.y[other]);
|
||||||
|
if (d < nearestPreyDist && d <= aggrRadiusSq) {
|
||||||
|
nearestPreyDist = d;
|
||||||
|
nearestPreyEid = other;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── State transitions ───
|
||||||
|
const currentState = AI.state[eid];
|
||||||
|
|
||||||
|
if (shouldFlee && currentState !== AIState.Flee) {
|
||||||
|
// Threat detected → flee
|
||||||
|
AI.state[eid] = AIState.Flee;
|
||||||
|
AI.stateTimer[eid] = 2000;
|
||||||
|
} else if (nearestPreyEid >= 0 && isHungry && currentState !== AIState.Attack) {
|
||||||
|
// Prey spotted and hungry → attack
|
||||||
|
AI.state[eid] = AIState.Attack;
|
||||||
|
AI.targetEid[eid] = nearestPreyEid;
|
||||||
|
AI.stateTimer[eid] = 5000;
|
||||||
|
} else if (isHungry && currentState !== AIState.Feed && currentState !== AIState.Flee && currentState !== AIState.Attack) {
|
||||||
|
// Hungry → feed (but don't interrupt active attack)
|
||||||
|
AI.state[eid] = AIState.Feed;
|
||||||
|
AI.stateTimer[eid] = 8000;
|
||||||
|
} else if (AI.stateTimer[eid] <= 0) {
|
||||||
|
// Timer expired → cycle between idle and wander
|
||||||
|
if (currentState === AIState.Idle) {
|
||||||
|
AI.state[eid] = AIState.Wander;
|
||||||
|
AI.stateTimer[eid] = 3000 + aiRandom(eid, tick) * 4000;
|
||||||
|
} else if (currentState === AIState.Wander) {
|
||||||
|
AI.state[eid] = AIState.Idle;
|
||||||
|
AI.stateTimer[eid] = 1000 + aiRandom(eid, tick) * 2000;
|
||||||
|
} else if (currentState === AIState.Feed) {
|
||||||
|
// Couldn't find food → wander
|
||||||
|
AI.state[eid] = AIState.Wander;
|
||||||
|
AI.stateTimer[eid] = 3000;
|
||||||
|
} else if (currentState === AIState.Flee) {
|
||||||
|
AI.state[eid] = AIState.Wander;
|
||||||
|
AI.stateTimer[eid] = 2000;
|
||||||
|
} else if (currentState === AIState.Attack) {
|
||||||
|
AI.state[eid] = AIState.Wander;
|
||||||
|
AI.stateTimer[eid] = 2000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Execute current state behavior ───
|
||||||
|
switch (AI.state[eid]) {
|
||||||
|
case AIState.Idle: {
|
||||||
|
Velocity.vx[eid] = 0;
|
||||||
|
Velocity.vy[eid] = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case AIState.Wander: {
|
||||||
|
// Random direction, clamped to wander radius from home
|
||||||
|
const homeDist = Math.sqrt(distSq(
|
||||||
|
Position.x[eid], Position.y[eid],
|
||||||
|
AI.homeX[eid], AI.homeY[eid],
|
||||||
|
));
|
||||||
|
|
||||||
|
if (homeDist > species.wanderRadius) {
|
||||||
|
// Too far from home → head back
|
||||||
|
moveToward(eid, AI.homeX[eid], AI.homeY[eid], speed * 0.6);
|
||||||
|
} else {
|
||||||
|
// Pick new random direction periodically
|
||||||
|
const phase = Math.floor(AI.stateTimer[eid] / 1000);
|
||||||
|
const angle = aiRandom(eid, tick + phase) * Math.PI * 2;
|
||||||
|
Velocity.vx[eid] = Math.cos(angle) * speed * 0.5;
|
||||||
|
Velocity.vy[eid] = Math.sin(angle) * speed * 0.5;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case AIState.Feed: {
|
||||||
|
if (species.diet === 'mineral') {
|
||||||
|
// Find nearest resource entity
|
||||||
|
const [nearResEid, nearResDist] = findNearest(
|
||||||
|
world,
|
||||||
|
Position.x[eid], Position.y[eid],
|
||||||
|
[...resources],
|
||||||
|
(species.wanderRadius * 2) ** 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (nearResEid >= 0) {
|
||||||
|
const feedRangeSq = 30 * 30;
|
||||||
|
if (nearResDist < feedRangeSq) {
|
||||||
|
// Close enough — feed (handled by metabolism system)
|
||||||
|
Velocity.vx[eid] = 0;
|
||||||
|
Velocity.vy[eid] = 0;
|
||||||
|
} else {
|
||||||
|
moveToward(eid, Position.x[nearResEid], Position.y[nearResEid], speed);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No resources found → wander to look for food
|
||||||
|
const angle = aiRandom(eid, tick) * Math.PI * 2;
|
||||||
|
Velocity.vx[eid] = Math.cos(angle) * speed * 0.5;
|
||||||
|
Velocity.vy[eid] = Math.sin(angle) * speed * 0.5;
|
||||||
|
}
|
||||||
|
} else if (species.diet === 'creature') {
|
||||||
|
// Predator — same as attack but passive searching
|
||||||
|
const angle = aiRandom(eid, tick) * Math.PI * 2;
|
||||||
|
Velocity.vx[eid] = Math.cos(angle) * speed * 0.7;
|
||||||
|
Velocity.vy[eid] = Math.sin(angle) * speed * 0.7;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case AIState.Flee: {
|
||||||
|
moveAway(eid, nearestThreatX, nearestThreatY, speed * 1.3);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case AIState.Attack: {
|
||||||
|
const target = AI.targetEid[eid];
|
||||||
|
if (target < 0) {
|
||||||
|
AI.state[eid] = AIState.Wander;
|
||||||
|
AI.stateTimer[eid] = 2000;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetDist = distSq(
|
||||||
|
Position.x[eid], Position.y[eid],
|
||||||
|
Position.x[target], Position.y[target],
|
||||||
|
);
|
||||||
|
const attackRangeSq = species.attackRange * species.attackRange;
|
||||||
|
|
||||||
|
if (targetDist <= attackRangeSq) {
|
||||||
|
// In range — deal damage if cooldown ready
|
||||||
|
Velocity.vx[eid] = 0;
|
||||||
|
Velocity.vy[eid] = 0;
|
||||||
|
if (AI.attackCooldown[eid] <= 0) {
|
||||||
|
const dmg = LifeCycle.stage[eid] === LifeStage.Youth
|
||||||
|
? species.damage * 0.5
|
||||||
|
: species.damage;
|
||||||
|
Health.current[target] -= dmg;
|
||||||
|
AI.attackCooldown[eid] = species.attackCooldown;
|
||||||
|
|
||||||
|
// Gain energy from kill if target dies
|
||||||
|
if (Health.current[target] <= 0) {
|
||||||
|
Metabolism.energy[eid] = Math.min(
|
||||||
|
Metabolism.energy[eid] + Metabolism.feedAmount[eid],
|
||||||
|
Metabolism.energyMax[eid],
|
||||||
|
);
|
||||||
|
AI.state[eid] = AIState.Wander;
|
||||||
|
AI.stateTimer[eid] = 3000;
|
||||||
|
AI.targetEid[eid] = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Chase target
|
||||||
|
moveToward(eid, Position.x[target], Position.y[target], speed);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
115
src/creatures/factory.ts
Normal file
115
src/creatures/factory.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* Creature Factory — creates creature entities in the ECS world
|
||||||
|
*
|
||||||
|
* Each creature gets: Position, Velocity, Health, SpriteRef,
|
||||||
|
* Creature, AI, Metabolism, LifeCycle components.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { addEntity, addComponent } from 'bitecs';
|
||||||
|
import type { World } from '../ecs/world';
|
||||||
|
import {
|
||||||
|
Position,
|
||||||
|
Velocity,
|
||||||
|
Health,
|
||||||
|
SpriteRef,
|
||||||
|
Creature,
|
||||||
|
AI,
|
||||||
|
Metabolism,
|
||||||
|
LifeCycle,
|
||||||
|
} from '../ecs/components';
|
||||||
|
import { AIState, LifeStage } from './types';
|
||||||
|
import type { SpeciesData, CreatureInfo } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a creature entity from species data at a given position.
|
||||||
|
* Starts at the Egg stage (immobile).
|
||||||
|
*
|
||||||
|
* @returns entity ID
|
||||||
|
*/
|
||||||
|
export function createCreatureEntity(
|
||||||
|
world: World,
|
||||||
|
species: SpeciesData,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
startStage: LifeStage = LifeStage.Egg,
|
||||||
|
): number {
|
||||||
|
const eid = addEntity(world);
|
||||||
|
|
||||||
|
// Position
|
||||||
|
addComponent(world, eid, Position);
|
||||||
|
Position.x[eid] = x;
|
||||||
|
Position.y[eid] = y;
|
||||||
|
|
||||||
|
// Velocity (starts at 0 — eggs don't move)
|
||||||
|
addComponent(world, eid, Velocity);
|
||||||
|
Velocity.vx[eid] = 0;
|
||||||
|
Velocity.vy[eid] = 0;
|
||||||
|
|
||||||
|
// Health — scaled by life stage
|
||||||
|
addComponent(world, eid, Health);
|
||||||
|
const healthScale = startStage === LifeStage.Egg ? 0.3
|
||||||
|
: startStage === LifeStage.Youth ? 0.6
|
||||||
|
: startStage === LifeStage.Aging ? 0.7
|
||||||
|
: 1.0;
|
||||||
|
const hp = Math.round(species.health * healthScale);
|
||||||
|
Health.current[eid] = hp;
|
||||||
|
Health.max[eid] = hp;
|
||||||
|
|
||||||
|
// Sprite — color from species, radius by stage
|
||||||
|
addComponent(world, eid, SpriteRef);
|
||||||
|
SpriteRef.color[eid] = parseInt(species.color.replace('#', ''), 16);
|
||||||
|
SpriteRef.radius[eid] = startStage <= LifeStage.Youth
|
||||||
|
? species.radiusYouth
|
||||||
|
: species.radius;
|
||||||
|
|
||||||
|
// Creature tag
|
||||||
|
addComponent(world, eid, Creature);
|
||||||
|
Creature.speciesId[eid] = species.speciesId;
|
||||||
|
|
||||||
|
// AI — starts idle
|
||||||
|
addComponent(world, eid, AI);
|
||||||
|
AI.state[eid] = startStage === LifeStage.Egg ? AIState.Idle : AIState.Wander;
|
||||||
|
AI.stateTimer[eid] = 2000; // 2s initial timer
|
||||||
|
AI.targetEid[eid] = -1;
|
||||||
|
AI.homeX[eid] = x;
|
||||||
|
AI.homeY[eid] = y;
|
||||||
|
AI.attackCooldown[eid] = 0;
|
||||||
|
|
||||||
|
// Metabolism
|
||||||
|
addComponent(world, eid, Metabolism);
|
||||||
|
Metabolism.energy[eid] = species.energyMax * 0.7; // start 70% full
|
||||||
|
Metabolism.energyMax[eid] = species.energyMax;
|
||||||
|
Metabolism.drainRate[eid] = species.energyDrainPerSecond;
|
||||||
|
Metabolism.feedAmount[eid] = species.energyPerFeed;
|
||||||
|
Metabolism.hungerThreshold[eid] = species.hungerThreshold;
|
||||||
|
|
||||||
|
// Life Cycle
|
||||||
|
addComponent(world, eid, LifeCycle);
|
||||||
|
LifeCycle.stage[eid] = startStage;
|
||||||
|
LifeCycle.age[eid] = 0;
|
||||||
|
|
||||||
|
// Set stage timer based on starting stage
|
||||||
|
const stageTimers = [
|
||||||
|
species.eggDuration,
|
||||||
|
species.youthDuration,
|
||||||
|
species.matureDuration,
|
||||||
|
species.agingDuration,
|
||||||
|
];
|
||||||
|
LifeCycle.stageTimer[eid] = stageTimers[startStage];
|
||||||
|
|
||||||
|
return eid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get runtime creature info for string-based lookups.
|
||||||
|
* Used by the creature data map (similar to resourceData pattern).
|
||||||
|
*/
|
||||||
|
export function getCreatureInfo(
|
||||||
|
eid: number,
|
||||||
|
speciesDataId: string,
|
||||||
|
): CreatureInfo {
|
||||||
|
return {
|
||||||
|
speciesId: Creature.speciesId[eid],
|
||||||
|
speciesDataId,
|
||||||
|
};
|
||||||
|
}
|
||||||
21
src/creatures/index.ts
Normal file
21
src/creatures/index.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Creatures module — re-exports for clean imports
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { SpeciesId, AIState, LifeStage, SpeciesRegistry } from './types';
|
||||||
|
export type { SpeciesData, CreatureInfo, DietType } from './types';
|
||||||
|
|
||||||
|
export { createCreatureEntity, getCreatureInfo } from './factory';
|
||||||
|
|
||||||
|
export { aiSystem } from './ai';
|
||||||
|
export { metabolismSystem, clearMetabolismTracking, resetMetabolismTracking } from './metabolism';
|
||||||
|
export { lifeCycleSystem } from './lifecycle';
|
||||||
|
export type { LifeCycleEvent } from './lifecycle';
|
||||||
|
export { countPopulations, spawnInitialCreatures, reproduce } from './population';
|
||||||
|
|
||||||
|
export {
|
||||||
|
creatureProjectileSystem,
|
||||||
|
getObservableCreatures,
|
||||||
|
creatureAttackPlayerSystem,
|
||||||
|
} from './interaction';
|
||||||
|
export type { CreatureObservation, ProjectileHit } from './interaction';
|
||||||
216
src/creatures/interaction.ts
Normal file
216
src/creatures/interaction.ts
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
/**
|
||||||
|
* Creature Interaction — projectile damage, player observation, basic taming
|
||||||
|
*
|
||||||
|
* Handles projectile-creature collision and player proximity detection.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { query } from 'bitecs';
|
||||||
|
import type { World } from '../ecs/world';
|
||||||
|
import {
|
||||||
|
Position,
|
||||||
|
Health,
|
||||||
|
Creature,
|
||||||
|
AI,
|
||||||
|
LifeCycle,
|
||||||
|
Projectile,
|
||||||
|
PlayerTag,
|
||||||
|
Metabolism,
|
||||||
|
} from '../ecs/components';
|
||||||
|
import { AIState, LifeStage } from './types';
|
||||||
|
import type { SpeciesData } from './types';
|
||||||
|
import type { ProjectileData } from '../player/projectile';
|
||||||
|
import { removeGameEntity } from '../ecs/factory';
|
||||||
|
|
||||||
|
/** Projectile hit radius squared (projectile + creature overlap) */
|
||||||
|
const HIT_RADIUS_SQ = 20 * 20;
|
||||||
|
|
||||||
|
/** Observation range — how close player must be to observe a creature */
|
||||||
|
const OBSERVE_RANGE_SQ = 60 * 60;
|
||||||
|
|
||||||
|
/** Base projectile damage */
|
||||||
|
const PROJECTILE_BASE_DAMAGE = 15;
|
||||||
|
|
||||||
|
/** Result of a creature observation */
|
||||||
|
export interface CreatureObservation {
|
||||||
|
eid: number;
|
||||||
|
speciesId: number;
|
||||||
|
healthPercent: number;
|
||||||
|
energyPercent: number;
|
||||||
|
stage: LifeStage;
|
||||||
|
aiState: AIState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of projectile-creature collision */
|
||||||
|
export interface ProjectileHit {
|
||||||
|
creatureEid: number;
|
||||||
|
projectileEid: number;
|
||||||
|
damage: number;
|
||||||
|
killed: boolean;
|
||||||
|
speciesId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check projectile-creature collisions.
|
||||||
|
* Removes hitting projectiles and deals damage to creatures.
|
||||||
|
*
|
||||||
|
* @param damageMultiplier — optional multiplier on projectile damage (default 1.0, e.g. Mechanic bonus)
|
||||||
|
* @returns Array of hits that occurred
|
||||||
|
*/
|
||||||
|
export function creatureProjectileSystem(
|
||||||
|
world: World,
|
||||||
|
projData: Map<number, ProjectileData>,
|
||||||
|
speciesLookup: Map<number, SpeciesData>,
|
||||||
|
damageMultiplier = 1.0,
|
||||||
|
): ProjectileHit[] {
|
||||||
|
const hits: ProjectileHit[] = [];
|
||||||
|
const projectiles = query(world, [Position, Projectile]);
|
||||||
|
const creatures = query(world, [Creature, Position, Health, LifeCycle]);
|
||||||
|
|
||||||
|
const projectilesToRemove: number[] = [];
|
||||||
|
|
||||||
|
for (const projEid of projectiles) {
|
||||||
|
const px = Position.x[projEid];
|
||||||
|
const py = Position.y[projEid];
|
||||||
|
|
||||||
|
for (const cEid of creatures) {
|
||||||
|
const dx = px - Position.x[cEid];
|
||||||
|
const dy = py - Position.y[cEid];
|
||||||
|
const dSq = dx * dx + dy * dy;
|
||||||
|
|
||||||
|
if (dSq <= HIT_RADIUS_SQ) {
|
||||||
|
// Hit! Calculate damage (with armor reduction and school bonus)
|
||||||
|
const species = speciesLookup.get(Creature.speciesId[cEid]);
|
||||||
|
const armor = species?.armor ?? 0;
|
||||||
|
const damage = Math.round(PROJECTILE_BASE_DAMAGE * (1 - armor) * damageMultiplier);
|
||||||
|
|
||||||
|
Health.current[cEid] -= damage;
|
||||||
|
const killed = Health.current[cEid] <= 0;
|
||||||
|
|
||||||
|
hits.push({
|
||||||
|
creatureEid: cEid,
|
||||||
|
projectileEid: projEid,
|
||||||
|
damage,
|
||||||
|
killed,
|
||||||
|
speciesId: Creature.speciesId[cEid],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Creature becomes aggressive or flees
|
||||||
|
if (!killed && species) {
|
||||||
|
if (species.aggressionRadius > 0) {
|
||||||
|
// Territorial/aggressive → attack the player
|
||||||
|
AI.state[cEid] = AIState.Attack;
|
||||||
|
AI.stateTimer[cEid] = 5000;
|
||||||
|
// Find nearest player to target
|
||||||
|
const players = query(world, [PlayerTag, Position]);
|
||||||
|
if (players.length > 0) {
|
||||||
|
AI.targetEid[cEid] = players[0];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Passive → flee
|
||||||
|
AI.state[cEid] = AIState.Flee;
|
||||||
|
AI.stateTimer[cEid] = 3000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
projectilesToRemove.push(projEid);
|
||||||
|
break; // one hit per projectile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove hit projectiles
|
||||||
|
for (const eid of projectilesToRemove) {
|
||||||
|
removeGameEntity(world, eid);
|
||||||
|
projData.delete(eid);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get observable creatures near the player.
|
||||||
|
* Returns info about creatures within observation range.
|
||||||
|
*/
|
||||||
|
export function getObservableCreatures(
|
||||||
|
world: World,
|
||||||
|
): CreatureObservation[] {
|
||||||
|
const observations: CreatureObservation[] = [];
|
||||||
|
const players = query(world, [PlayerTag, Position]);
|
||||||
|
if (players.length === 0) return observations;
|
||||||
|
|
||||||
|
const playerX = Position.x[players[0]];
|
||||||
|
const playerY = Position.y[players[0]];
|
||||||
|
|
||||||
|
const creatures = query(world, [Creature, Position, Health, LifeCycle, Metabolism]);
|
||||||
|
|
||||||
|
for (const eid of creatures) {
|
||||||
|
const dx = playerX - Position.x[eid];
|
||||||
|
const dy = playerY - Position.y[eid];
|
||||||
|
const dSq = dx * dx + dy * dy;
|
||||||
|
|
||||||
|
if (dSq <= OBSERVE_RANGE_SQ) {
|
||||||
|
observations.push({
|
||||||
|
eid,
|
||||||
|
speciesId: Creature.speciesId[eid],
|
||||||
|
healthPercent: Health.max[eid] > 0
|
||||||
|
? Math.round((Health.current[eid] / Health.max[eid]) * 100)
|
||||||
|
: 0,
|
||||||
|
energyPercent: Metabolism.energyMax[eid] > 0
|
||||||
|
? Math.round((Metabolism.energy[eid] / Metabolism.energyMax[eid]) * 100)
|
||||||
|
: 0,
|
||||||
|
stage: LifeCycle.stage[eid] as LifeStage,
|
||||||
|
aiState: AI.state[eid] as AIState,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return observations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if player is near any creature for attack damage (creature → player).
|
||||||
|
* Returns total damage dealt to player this frame.
|
||||||
|
*/
|
||||||
|
export function creatureAttackPlayerSystem(
|
||||||
|
world: World,
|
||||||
|
speciesLookup: Map<number, SpeciesData>,
|
||||||
|
): number {
|
||||||
|
let totalDamage = 0;
|
||||||
|
|
||||||
|
const players = query(world, [PlayerTag, Position, Health]);
|
||||||
|
if (players.length === 0) return 0;
|
||||||
|
|
||||||
|
const playerEid = players[0];
|
||||||
|
const playerX = Position.x[playerEid];
|
||||||
|
const playerY = Position.y[playerEid];
|
||||||
|
|
||||||
|
const creatures = query(world, [Creature, AI, Position, LifeCycle]);
|
||||||
|
|
||||||
|
for (const eid of creatures) {
|
||||||
|
if (AI.state[eid] !== AIState.Attack) continue;
|
||||||
|
if (AI.targetEid[eid] !== playerEid) continue;
|
||||||
|
if (LifeCycle.stage[eid] === LifeStage.Egg) continue;
|
||||||
|
|
||||||
|
const species = speciesLookup.get(Creature.speciesId[eid]);
|
||||||
|
if (!species) continue;
|
||||||
|
|
||||||
|
const dx = playerX - Position.x[eid];
|
||||||
|
const dy = playerY - Position.y[eid];
|
||||||
|
const dSq = dx * dx + dy * dy;
|
||||||
|
const rangeSq = species.attackRange * species.attackRange;
|
||||||
|
|
||||||
|
if (dSq <= rangeSq && AI.attackCooldown[eid] <= 0) {
|
||||||
|
const dmg = LifeCycle.stage[eid] === LifeStage.Youth
|
||||||
|
? species.damage * 0.5
|
||||||
|
: species.damage;
|
||||||
|
totalDamage += dmg;
|
||||||
|
AI.attackCooldown[eid] = species.attackCooldown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalDamage > 0) {
|
||||||
|
Health.current[playerEid] -= totalDamage;
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalDamage;
|
||||||
|
}
|
||||||
152
src/creatures/lifecycle.ts
Normal file
152
src/creatures/lifecycle.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* Life Cycle System — manages creature aging through stages
|
||||||
|
*
|
||||||
|
* Egg → Youth → Mature → Aging → Death
|
||||||
|
*
|
||||||
|
* Each stage has a timer. When it expires, creature advances to next stage.
|
||||||
|
* Stats (health, speed, size) change with each stage.
|
||||||
|
* Death at end of Aging stage returns entity ID for removal + decomposition.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { query } from 'bitecs';
|
||||||
|
import type { World } from '../ecs/world';
|
||||||
|
import {
|
||||||
|
Health,
|
||||||
|
SpriteRef,
|
||||||
|
Creature,
|
||||||
|
AI,
|
||||||
|
Metabolism,
|
||||||
|
LifeCycle,
|
||||||
|
} from '../ecs/components';
|
||||||
|
import { AIState, LifeStage } from './types';
|
||||||
|
import type { SpeciesData } from './types';
|
||||||
|
|
||||||
|
/** Result of life cycle processing */
|
||||||
|
export interface LifeCycleEvent {
|
||||||
|
type: 'stage_advance' | 'natural_death' | 'ready_to_reproduce';
|
||||||
|
eid: number;
|
||||||
|
speciesId: number;
|
||||||
|
newStage?: LifeStage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Life cycle system — advances stages, adjusts stats, detects natural death.
|
||||||
|
*
|
||||||
|
* @returns Array of lifecycle events (stage advances, natural deaths, reproduction readiness)
|
||||||
|
*/
|
||||||
|
export function lifeCycleSystem(
|
||||||
|
world: World,
|
||||||
|
deltaMs: number,
|
||||||
|
speciesLookup: Map<number, SpeciesData>,
|
||||||
|
): LifeCycleEvent[] {
|
||||||
|
const events: LifeCycleEvent[] = [];
|
||||||
|
|
||||||
|
for (const eid of query(world, [Creature, LifeCycle])) {
|
||||||
|
const species = speciesLookup.get(Creature.speciesId[eid]);
|
||||||
|
if (!species) continue;
|
||||||
|
|
||||||
|
// Advance age
|
||||||
|
LifeCycle.age[eid] += deltaMs;
|
||||||
|
|
||||||
|
// Decrease stage timer
|
||||||
|
LifeCycle.stageTimer[eid] -= deltaMs;
|
||||||
|
|
||||||
|
// Check for reproduction readiness (mature with enough energy)
|
||||||
|
if (LifeCycle.stage[eid] === LifeStage.Mature) {
|
||||||
|
const energyFraction = Metabolism.energy[eid] / Metabolism.energyMax[eid];
|
||||||
|
if (energyFraction > 0.8) {
|
||||||
|
events.push({
|
||||||
|
type: 'ready_to_reproduce',
|
||||||
|
eid,
|
||||||
|
speciesId: Creature.speciesId[eid],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage transition when timer expires
|
||||||
|
if (LifeCycle.stageTimer[eid] <= 0) {
|
||||||
|
const currentStage = LifeCycle.stage[eid] as LifeStage;
|
||||||
|
|
||||||
|
switch (currentStage) {
|
||||||
|
case LifeStage.Egg: {
|
||||||
|
// Hatch → Youth
|
||||||
|
LifeCycle.stage[eid] = LifeStage.Youth;
|
||||||
|
LifeCycle.stageTimer[eid] = species.youthDuration;
|
||||||
|
|
||||||
|
// Update visuals — still small
|
||||||
|
SpriteRef.radius[eid] = species.radiusYouth;
|
||||||
|
|
||||||
|
// Enable AI
|
||||||
|
AI.state[eid] = AIState.Wander;
|
||||||
|
AI.stateTimer[eid] = 2000;
|
||||||
|
|
||||||
|
// Scale health up
|
||||||
|
const youthHp = Math.round(species.health * 0.6);
|
||||||
|
Health.current[eid] = youthHp;
|
||||||
|
Health.max[eid] = youthHp;
|
||||||
|
|
||||||
|
events.push({
|
||||||
|
type: 'stage_advance',
|
||||||
|
eid,
|
||||||
|
speciesId: Creature.speciesId[eid],
|
||||||
|
newStage: LifeStage.Youth,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case LifeStage.Youth: {
|
||||||
|
// Grow → Mature
|
||||||
|
LifeCycle.stage[eid] = LifeStage.Mature;
|
||||||
|
LifeCycle.stageTimer[eid] = species.matureDuration;
|
||||||
|
|
||||||
|
// Full size and health
|
||||||
|
SpriteRef.radius[eid] = species.radius;
|
||||||
|
Health.current[eid] = species.health;
|
||||||
|
Health.max[eid] = species.health;
|
||||||
|
|
||||||
|
events.push({
|
||||||
|
type: 'stage_advance',
|
||||||
|
eid,
|
||||||
|
speciesId: Creature.speciesId[eid],
|
||||||
|
newStage: LifeStage.Mature,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case LifeStage.Mature: {
|
||||||
|
// Age → Aging
|
||||||
|
LifeCycle.stage[eid] = LifeStage.Aging;
|
||||||
|
LifeCycle.stageTimer[eid] = species.agingDuration;
|
||||||
|
|
||||||
|
// Reduced stats
|
||||||
|
const agingHp = Math.round(species.health * 0.7);
|
||||||
|
Health.max[eid] = agingHp;
|
||||||
|
if (Health.current[eid] > agingHp) {
|
||||||
|
Health.current[eid] = agingHp;
|
||||||
|
}
|
||||||
|
|
||||||
|
events.push({
|
||||||
|
type: 'stage_advance',
|
||||||
|
eid,
|
||||||
|
speciesId: Creature.speciesId[eid],
|
||||||
|
newStage: LifeStage.Aging,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case LifeStage.Aging: {
|
||||||
|
// Natural death
|
||||||
|
Health.current[eid] = 0;
|
||||||
|
events.push({
|
||||||
|
type: 'natural_death',
|
||||||
|
eid,
|
||||||
|
speciesId: Creature.speciesId[eid],
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return events;
|
||||||
|
}
|
||||||
124
src/creatures/metabolism.ts
Normal file
124
src/creatures/metabolism.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* Metabolism System — energy drain, feeding, starvation
|
||||||
|
*
|
||||||
|
* Every creature loses energy over time. Feeding near resources restores energy.
|
||||||
|
* Starvation (energy = 0) deals damage over time.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { query } from 'bitecs';
|
||||||
|
import type { World } from '../ecs/world';
|
||||||
|
import {
|
||||||
|
Position,
|
||||||
|
Health,
|
||||||
|
Creature,
|
||||||
|
AI,
|
||||||
|
Metabolism,
|
||||||
|
LifeCycle,
|
||||||
|
Resource,
|
||||||
|
} from '../ecs/components';
|
||||||
|
import { AIState, LifeStage } from './types';
|
||||||
|
import type { ResourceInfo } from '../player/interaction';
|
||||||
|
|
||||||
|
/** Damage per second when starving (energy = 0) */
|
||||||
|
const STARVATION_DAMAGE_PER_SEC = 5;
|
||||||
|
|
||||||
|
/** Feed range squared (pixels) — how close creature must be to resource */
|
||||||
|
const FEED_RANGE_SQ = 35 * 35;
|
||||||
|
|
||||||
|
/** Minimum time between feeds (ms) */
|
||||||
|
const FEED_COOLDOWN_MS = 2000;
|
||||||
|
|
||||||
|
/** Tracking last feed time per creature to prevent instant depletion */
|
||||||
|
const lastFeedTime = new Map<number, number>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metabolism system — drains energy, handles feeding from resources, starvation damage.
|
||||||
|
*
|
||||||
|
* @param resourceData - map of resource entity ID → info (for depletion tracking)
|
||||||
|
* @param elapsed - total elapsed time in ms (for feed cooldown)
|
||||||
|
* @returns Array of [creatureEid, elementNumber] pairs for excretion events
|
||||||
|
*/
|
||||||
|
export function metabolismSystem(
|
||||||
|
world: World,
|
||||||
|
deltaMs: number,
|
||||||
|
resourceData: Map<number, ResourceInfo>,
|
||||||
|
elapsed: number,
|
||||||
|
): Array<[number, number]> {
|
||||||
|
const dt = deltaMs / 1000;
|
||||||
|
const excretions: Array<[number, number]> = [];
|
||||||
|
|
||||||
|
const creatures = query(world, [Creature, Metabolism, Position, LifeCycle]);
|
||||||
|
const resources = query(world, [Resource, Position]);
|
||||||
|
|
||||||
|
for (const eid of creatures) {
|
||||||
|
// Eggs don't metabolize
|
||||||
|
if (LifeCycle.stage[eid] === LifeStage.Egg) continue;
|
||||||
|
|
||||||
|
// ─── Energy drain ───
|
||||||
|
const drainMultiplier = LifeCycle.stage[eid] === LifeStage.Youth ? 0.7
|
||||||
|
: LifeCycle.stage[eid] === LifeStage.Aging ? 1.3
|
||||||
|
: 1.0;
|
||||||
|
|
||||||
|
Metabolism.energy[eid] -= Metabolism.drainRate[eid] * dt * drainMultiplier;
|
||||||
|
|
||||||
|
// ─── Feeding (if in Feed state and near resource) ───
|
||||||
|
if (AI.state[eid] === AIState.Feed || AI.state[eid] === AIState.Idle) {
|
||||||
|
const lastFed = lastFeedTime.get(eid) ?? 0;
|
||||||
|
if (elapsed - lastFed >= FEED_COOLDOWN_MS) {
|
||||||
|
for (const resEid of resources) {
|
||||||
|
if (Resource.quantity[resEid] <= 0) continue;
|
||||||
|
|
||||||
|
const dx = Position.x[eid] - Position.x[resEid];
|
||||||
|
const dy = Position.y[eid] - Position.y[resEid];
|
||||||
|
const dSq = dx * dx + dy * dy;
|
||||||
|
|
||||||
|
if (dSq <= FEED_RANGE_SQ) {
|
||||||
|
// Feed!
|
||||||
|
Metabolism.energy[eid] = Math.min(
|
||||||
|
Metabolism.energy[eid] + Metabolism.feedAmount[eid],
|
||||||
|
Metabolism.energyMax[eid],
|
||||||
|
);
|
||||||
|
Resource.quantity[resEid] -= 1;
|
||||||
|
lastFeedTime.set(eid, elapsed);
|
||||||
|
|
||||||
|
// Excretion event (species excretes element after feeding)
|
||||||
|
const speciesId = Creature.speciesId[eid];
|
||||||
|
// Excretion element is stored in species data, we pass speciesId
|
||||||
|
// and let the caller resolve what element is excreted
|
||||||
|
excretions.push([eid, speciesId]);
|
||||||
|
|
||||||
|
// Exit Feed state if energy is above threshold
|
||||||
|
const fraction = Metabolism.energy[eid] / Metabolism.energyMax[eid];
|
||||||
|
if (fraction >= Metabolism.hungerThreshold[eid] * 1.5) {
|
||||||
|
AI.state[eid] = AIState.Wander;
|
||||||
|
AI.stateTimer[eid] = 3000;
|
||||||
|
}
|
||||||
|
break; // one feed per tick
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Clamp energy ───
|
||||||
|
if (Metabolism.energy[eid] < 0) {
|
||||||
|
Metabolism.energy[eid] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Starvation damage ───
|
||||||
|
if (Metabolism.energy[eid] <= 0) {
|
||||||
|
Health.current[eid] -= STARVATION_DAMAGE_PER_SEC * dt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return excretions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear tracking data for a removed creature */
|
||||||
|
export function clearMetabolismTracking(eid: number): void {
|
||||||
|
lastFeedTime.delete(eid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset all metabolism tracking (for tests) */
|
||||||
|
export function resetMetabolismTracking(): void {
|
||||||
|
lastFeedTime.clear();
|
||||||
|
}
|
||||||
170
src/creatures/population.ts
Normal file
170
src/creatures/population.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* Population Dynamics — spawning, reproduction, population caps
|
||||||
|
*
|
||||||
|
* Simplified Lotka-Volterra inspired system:
|
||||||
|
* - Reproduction when mature creatures have high energy
|
||||||
|
* - Population caps per species prevent explosion
|
||||||
|
* - Initial spawning on preferred tiles
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { query } from 'bitecs';
|
||||||
|
import type { World } from '../ecs/world';
|
||||||
|
import { Position, Creature, LifeCycle } from '../ecs/components';
|
||||||
|
import { LifeStage, SpeciesId } from './types';
|
||||||
|
import type { SpeciesData, CreatureInfo } from './types';
|
||||||
|
import { createCreatureEntity } from './factory';
|
||||||
|
import type { TileGrid, BiomeData } from '../world/types';
|
||||||
|
|
||||||
|
/** Simple seeded hash for deterministic spawn placement */
|
||||||
|
function spawnHash(x: number, y: number, seed: number, salt: number): number {
|
||||||
|
return (((x * 48611) ^ (y * 29423) ^ (seed * 61379) ^ (salt * 73757)) >>> 0) / 4294967296;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Count living creatures per species */
|
||||||
|
export function countPopulations(world: World): Map<number, number> {
|
||||||
|
const counts = new Map<number, number>();
|
||||||
|
for (const eid of query(world, [Creature])) {
|
||||||
|
const sid = Creature.speciesId[eid];
|
||||||
|
counts.set(sid, (counts.get(sid) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawn initial creatures across the map.
|
||||||
|
* Places creatures on preferred tiles using seeded randomness.
|
||||||
|
*
|
||||||
|
* @returns Map of entity ID → CreatureInfo
|
||||||
|
*/
|
||||||
|
export function spawnInitialCreatures(
|
||||||
|
world: World,
|
||||||
|
grid: TileGrid,
|
||||||
|
biome: BiomeData,
|
||||||
|
seed: number,
|
||||||
|
allSpecies: SpeciesData[],
|
||||||
|
): Map<number, CreatureInfo> {
|
||||||
|
const creatureData = new Map<number, CreatureInfo>();
|
||||||
|
const tileSize = biome.tileSize;
|
||||||
|
|
||||||
|
// Build tile name → id map
|
||||||
|
const tileNameToId = new Map<string, number>();
|
||||||
|
for (const tile of biome.tiles) {
|
||||||
|
tileNameToId.set(tile.name, tile.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const species of allSpecies) {
|
||||||
|
// Find preferred tile IDs
|
||||||
|
const preferredIds = new Set<number>();
|
||||||
|
for (const name of species.preferredTiles) {
|
||||||
|
const id = tileNameToId.get(name);
|
||||||
|
if (id !== undefined) preferredIds.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect candidate positions
|
||||||
|
const candidates: Array<{ x: number; y: number }> = [];
|
||||||
|
for (let y = 0; y < grid.length; y++) {
|
||||||
|
for (let x = 0; x < grid[y].length; x++) {
|
||||||
|
if (preferredIds.has(grid[y][x])) {
|
||||||
|
candidates.push({ x, y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn based on weight — deterministic selection
|
||||||
|
const targetCount = Math.min(
|
||||||
|
Math.round(species.maxPopulation * species.spawnWeight),
|
||||||
|
species.maxPopulation,
|
||||||
|
);
|
||||||
|
|
||||||
|
let spawned = 0;
|
||||||
|
for (let i = 0; i < candidates.length && spawned < targetCount; i++) {
|
||||||
|
const { x, y } = candidates[i];
|
||||||
|
const roll = spawnHash(x, y, seed, species.speciesId);
|
||||||
|
|
||||||
|
// Probabilistic placement — spread creatures out
|
||||||
|
if (roll < species.spawnWeight * 0.05) {
|
||||||
|
const px = x * tileSize + tileSize / 2;
|
||||||
|
const py = y * tileSize + tileSize / 2;
|
||||||
|
|
||||||
|
// Start mature for initial population
|
||||||
|
const eid = createCreatureEntity(world, species, px, py, LifeStage.Mature);
|
||||||
|
creatureData.set(eid, {
|
||||||
|
speciesId: species.speciesId,
|
||||||
|
speciesDataId: species.id,
|
||||||
|
});
|
||||||
|
spawned++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: if not enough spawned, place remaining at random candidates
|
||||||
|
if (spawned < targetCount && candidates.length > 0) {
|
||||||
|
for (let i = 0; spawned < targetCount && i < 1000; i++) {
|
||||||
|
const idx = Math.floor(spawnHash(i, spawned, seed, species.speciesId + 100) * candidates.length);
|
||||||
|
const { x, y } = candidates[idx];
|
||||||
|
const px = x * tileSize + tileSize / 2;
|
||||||
|
const py = y * tileSize + tileSize / 2;
|
||||||
|
|
||||||
|
const eid = createCreatureEntity(world, species, px, py, LifeStage.Mature);
|
||||||
|
creatureData.set(eid, {
|
||||||
|
speciesId: species.speciesId,
|
||||||
|
speciesDataId: species.id,
|
||||||
|
});
|
||||||
|
spawned++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return creatureData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle reproduction — create offspring near parent.
|
||||||
|
* Called when lifecycle system reports 'ready_to_reproduce' events.
|
||||||
|
*
|
||||||
|
* @param parentEid - entity ID of the reproducing creature
|
||||||
|
* @param species - species data
|
||||||
|
* @param currentPopulation - current count of this species
|
||||||
|
* @returns Array of new creature entity IDs (empty if population cap reached)
|
||||||
|
*/
|
||||||
|
export function reproduce(
|
||||||
|
world: World,
|
||||||
|
parentEid: number,
|
||||||
|
species: SpeciesData,
|
||||||
|
currentPopulation: number,
|
||||||
|
creatureData: Map<number, CreatureInfo>,
|
||||||
|
): number[] {
|
||||||
|
if (currentPopulation >= species.maxPopulation) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const newEids: number[] = [];
|
||||||
|
const maxOffspring = Math.min(
|
||||||
|
species.offspringCount,
|
||||||
|
species.maxPopulation - currentPopulation,
|
||||||
|
);
|
||||||
|
|
||||||
|
const parentX = Position.x[parentEid];
|
||||||
|
const parentY = Position.y[parentEid];
|
||||||
|
|
||||||
|
for (let i = 0; i < maxOffspring; i++) {
|
||||||
|
// Place egg near parent with slight offset
|
||||||
|
const angle = (i / maxOffspring) * Math.PI * 2;
|
||||||
|
const offsetX = Math.cos(angle) * 20;
|
||||||
|
const offsetY = Math.sin(angle) * 20;
|
||||||
|
|
||||||
|
const eid = createCreatureEntity(
|
||||||
|
world, species,
|
||||||
|
parentX + offsetX,
|
||||||
|
parentY + offsetY,
|
||||||
|
LifeStage.Egg,
|
||||||
|
);
|
||||||
|
|
||||||
|
creatureData.set(eid, {
|
||||||
|
speciesId: species.speciesId,
|
||||||
|
speciesDataId: species.id,
|
||||||
|
});
|
||||||
|
newEids.push(eid);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newEids;
|
||||||
|
}
|
||||||
132
src/creatures/types.ts
Normal file
132
src/creatures/types.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* Creature Types — data definitions for the creature/ecology system
|
||||||
|
*
|
||||||
|
* Catalytic Wastes: Crystallids, Acidophiles, Reagents
|
||||||
|
* Kinetic Mountains: Pendulums, Mechanoids, Resonators
|
||||||
|
* Verdant Forests: Symbiotes, Mimics, Spore-bearers
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Species identifier (stored as numeric ID in ECS components) */
|
||||||
|
export enum SpeciesId {
|
||||||
|
Crystallid = 0,
|
||||||
|
Acidophile = 1,
|
||||||
|
Reagent = 2,
|
||||||
|
Pendulum = 3,
|
||||||
|
Mechanoid = 4,
|
||||||
|
Resonator = 5,
|
||||||
|
Symbiote = 6,
|
||||||
|
Mimic = 7,
|
||||||
|
SporeBearer = 8,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AI behavior state (FSM) */
|
||||||
|
export enum AIState {
|
||||||
|
Idle = 0,
|
||||||
|
Wander = 1,
|
||||||
|
Feed = 2,
|
||||||
|
Flee = 3,
|
||||||
|
Attack = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Life cycle stage */
|
||||||
|
export enum LifeStage {
|
||||||
|
Egg = 0,
|
||||||
|
Youth = 1,
|
||||||
|
Mature = 2,
|
||||||
|
Aging = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Diet type — what a creature can feed on */
|
||||||
|
export type DietType = 'mineral' | 'acid' | 'creature';
|
||||||
|
|
||||||
|
/** Species definition loaded from creatures.json */
|
||||||
|
export interface SpeciesData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
nameRu: string;
|
||||||
|
speciesId: SpeciesId;
|
||||||
|
biome: string; // biome id this species belongs to
|
||||||
|
description: string;
|
||||||
|
descriptionRu: string;
|
||||||
|
|
||||||
|
/** Visual */
|
||||||
|
color: string; // hex color string
|
||||||
|
radius: number; // base radius in pixels
|
||||||
|
radiusYouth: number; // smaller when young
|
||||||
|
|
||||||
|
/** Stats */
|
||||||
|
health: number;
|
||||||
|
speed: number; // pixels per second
|
||||||
|
damage: number; // damage per attack
|
||||||
|
armor: number; // damage reduction (0-1)
|
||||||
|
|
||||||
|
/** Metabolism */
|
||||||
|
diet: DietType;
|
||||||
|
dietTiles: string[]; // tile names this species feeds on
|
||||||
|
excretionElement: number; // atomic number of excreted element (0 = none)
|
||||||
|
energyMax: number; // max energy capacity
|
||||||
|
energyPerFeed: number; // energy gained per feeding
|
||||||
|
energyDrainPerSecond: number; // passive energy loss rate
|
||||||
|
hungerThreshold: number; // below this → seek food (0-1 fraction of max)
|
||||||
|
|
||||||
|
/** Behavior */
|
||||||
|
aggressionRadius: number; // distance to detect threats
|
||||||
|
fleeRadius: number; // distance to start fleeing
|
||||||
|
wanderRadius: number; // max wander distance from home
|
||||||
|
attackRange: number; // melee attack range
|
||||||
|
attackCooldown: number; // ms between attacks
|
||||||
|
|
||||||
|
/** Life cycle durations (ms) */
|
||||||
|
eggDuration: number;
|
||||||
|
youthDuration: number;
|
||||||
|
matureDuration: number;
|
||||||
|
agingDuration: number;
|
||||||
|
|
||||||
|
/** Reproduction */
|
||||||
|
reproductionEnergy: number; // energy cost to reproduce
|
||||||
|
offspringCount: number; // eggs per reproduction event
|
||||||
|
|
||||||
|
/** Population */
|
||||||
|
maxPopulation: number; // per-species cap
|
||||||
|
spawnWeight: number; // relative spawn density (0-1)
|
||||||
|
preferredTiles: string[]; // tiles where this species spawns
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runtime creature state (non-ECS, for string data lookup) */
|
||||||
|
export interface CreatureInfo {
|
||||||
|
speciesId: SpeciesId;
|
||||||
|
speciesDataId: string; // key into species registry
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creature species registry */
|
||||||
|
export class SpeciesRegistry {
|
||||||
|
private byId = new Map<string, SpeciesData>();
|
||||||
|
private byNumericId = new Map<SpeciesId, SpeciesData>();
|
||||||
|
|
||||||
|
constructor(data: SpeciesData[]) {
|
||||||
|
for (const species of data) {
|
||||||
|
this.byId.set(species.id, species);
|
||||||
|
this.byNumericId.set(species.speciesId, species);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get species by string ID */
|
||||||
|
get(id: string): SpeciesData | undefined {
|
||||||
|
return this.byId.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get species by numeric ID (from ECS component) */
|
||||||
|
getByNumericId(id: SpeciesId): SpeciesData | undefined {
|
||||||
|
return this.byNumericId.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get all species */
|
||||||
|
getAll(): SpeciesData[] {
|
||||||
|
return [...this.byId.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get species count */
|
||||||
|
get count(): number {
|
||||||
|
return this.byId.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,5 +34,77 @@
|
|||||||
"geyserOnTile": 4,
|
"geyserOnTile": 4,
|
||||||
"mineralOnTiles": [0, 1, 2]
|
"mineralOnTiles": [0, 1, 2]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "kinetic-mountains",
|
||||||
|
"name": "Kinetic Mountains",
|
||||||
|
"nameRu": "Кинетические Горы",
|
||||||
|
"description": "Towering cliffs embedded with ancient gears and pendulums. Anomalous gravity zones, avalanche-prone slopes, and whirring mechanical ruins.",
|
||||||
|
"descriptionRu": "Высокие скалы с вросшими древними шестернями и маятниками. Зоны аномальной гравитации, лавиноопасные склоны и гудящие механические руины.",
|
||||||
|
"tileSize": 32,
|
||||||
|
"mapWidth": 80,
|
||||||
|
"mapHeight": 80,
|
||||||
|
"tiles": [
|
||||||
|
{ "id": 0, "name": "bare-rock", "nameRu": "Голый камень", "color": "#5a5a5a", "walkable": true, "damage": 0, "interactive": false, "resource": false },
|
||||||
|
{ "id": 1, "name": "gravel-slope", "nameRu": "Гравийный склон", "color": "#7a7a6e", "walkable": true, "damage": 0, "interactive": false, "resource": false },
|
||||||
|
{ "id": 2, "name": "iron-ridge", "nameRu": "Железный хребет", "color": "#4a3a2a", "walkable": true, "damage": 0, "interactive": false, "resource": false },
|
||||||
|
{ "id": 3, "name": "chasm", "nameRu": "Пропасть", "color": "#0a0a1a", "walkable": false, "damage": 15, "interactive": false, "resource": false },
|
||||||
|
{ "id": 4, "name": "gear-floor", "nameRu": "Шестерёночный пол", "color": "#8b7355", "walkable": true, "damage": 0, "interactive": false, "resource": false },
|
||||||
|
{ "id": 5, "name": "magnetic-field", "nameRu": "Магнитное поле", "color": "#3344aa", "walkable": true, "damage": 2, "interactive": false, "resource": false },
|
||||||
|
{ "id": 6, "name": "steam-vent", "nameRu": "Паровой клапан", "color": "#cccccc", "walkable": false, "damage": 0, "interactive": true, "resource": false },
|
||||||
|
{ "id": 7, "name": "ore-deposit", "nameRu": "Рудное месторождение", "color": "#b87333", "walkable": true, "damage": 0, "interactive": false, "resource": true }
|
||||||
|
],
|
||||||
|
"generation": {
|
||||||
|
"elevationScale": 0.05,
|
||||||
|
"detailScale": 0.18,
|
||||||
|
"elevationRules": [
|
||||||
|
{ "below": 0.15, "tileId": 3 },
|
||||||
|
{ "below": 0.25, "tileId": 5 },
|
||||||
|
{ "below": 0.50, "tileId": 0 },
|
||||||
|
{ "below": 0.68, "tileId": 1 },
|
||||||
|
{ "below": 0.82, "tileId": 2 },
|
||||||
|
{ "below": 1.00, "tileId": 4 }
|
||||||
|
],
|
||||||
|
"geyserThreshold": 0.92,
|
||||||
|
"mineralThreshold": 0.88,
|
||||||
|
"geyserOnTile": 5,
|
||||||
|
"mineralOnTiles": [0, 1, 2]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "verdant-forests",
|
||||||
|
"name": "Verdant Forests",
|
||||||
|
"nameRu": "Вердантовые Леса",
|
||||||
|
"description": "A multi-layered living forest teeming with biodiversity. Underground mycorrhizal networks, towering canopies, and bioluminescent clearings.",
|
||||||
|
"descriptionRu": "Многоярусный живой лес, кишащий биоразнообразием. Подземные микоризные сети, высочайшие кроны и биолюминесцентные поляны.",
|
||||||
|
"tileSize": 32,
|
||||||
|
"mapWidth": 80,
|
||||||
|
"mapHeight": 80,
|
||||||
|
"tiles": [
|
||||||
|
{ "id": 0, "name": "forest-floor", "nameRu": "Лесная подстилка", "color": "#1a3a0e", "walkable": true, "damage": 0, "interactive": false, "resource": false },
|
||||||
|
{ "id": 1, "name": "dense-undergrowth", "nameRu": "Густой подлесок", "color": "#0d2b06", "walkable": true, "damage": 0, "interactive": false, "resource": false },
|
||||||
|
{ "id": 2, "name": "mycelium-carpet", "nameRu": "Мицелиевый ковёр", "color": "#4a2a5a", "walkable": true, "damage": 0, "interactive": false, "resource": false },
|
||||||
|
{ "id": 3, "name": "bog", "nameRu": "Трясина", "color": "#2a4a1a", "walkable": true, "damage": 4, "interactive": false, "resource": false },
|
||||||
|
{ "id": 4, "name": "toxic-bloom", "nameRu": "Ядовитый цвет", "color": "#9a3a6a", "walkable": false, "damage": 8, "interactive": false, "resource": false },
|
||||||
|
{ "id": 5, "name": "ancient-tree", "nameRu": "Древо-Великан", "color": "#1a2a0a", "walkable": false, "damage": 0, "interactive": false, "resource": false },
|
||||||
|
{ "id": 6, "name": "hollow-stump", "nameRu": "Полый пень", "color": "#5a4a2a", "walkable": true, "damage": 0, "interactive": true, "resource": false },
|
||||||
|
{ "id": 7, "name": "herb-patch", "nameRu": "Лекарственная поляна", "color": "#2a6a1a", "walkable": true, "damage": 0, "interactive": false, "resource": true }
|
||||||
|
],
|
||||||
|
"generation": {
|
||||||
|
"elevationScale": 0.07,
|
||||||
|
"detailScale": 0.12,
|
||||||
|
"elevationRules": [
|
||||||
|
{ "below": 0.18, "tileId": 3 },
|
||||||
|
{ "below": 0.28, "tileId": 4 },
|
||||||
|
{ "below": 0.55, "tileId": 0 },
|
||||||
|
{ "below": 0.72, "tileId": 1 },
|
||||||
|
{ "below": 0.86, "tileId": 2 },
|
||||||
|
{ "below": 1.00, "tileId": 5 }
|
||||||
|
],
|
||||||
|
"geyserThreshold": 0.91,
|
||||||
|
"mineralThreshold": 0.87,
|
||||||
|
"geyserOnTile": 2,
|
||||||
|
"mineralOnTiles": [0, 1, 2]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
31
src/data/bosses.json
Normal file
31
src/data/bosses.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "ouroboros",
|
||||||
|
"name": "Ouroboros",
|
||||||
|
"nameRu": "Уроборос",
|
||||||
|
"description": "The Archon of Cycles — a serpent consuming its own tail in endless self-catalysis. Its body is both catalyst and substrate.",
|
||||||
|
"descriptionRu": "Архонт Циклов — змей, пожирающий собственный хвост в бесконечном самокатализе. Его тело — и катализатор, и субстрат.",
|
||||||
|
"color": "#cc44ff",
|
||||||
|
"radius": 20,
|
||||||
|
"health": 300,
|
||||||
|
"armor": 0.5,
|
||||||
|
"armorVulnerable": 0.1,
|
||||||
|
"regenPerSecond": 5,
|
||||||
|
"damage": 15,
|
||||||
|
"phaseDurations": [5000, 8000, 6000, 4000],
|
||||||
|
"phaseSpeedupPerCycle": 0.9,
|
||||||
|
"maxCycles": 5,
|
||||||
|
"vulnerablePhases": [3],
|
||||||
|
"chemicalWeakness": "NaOH",
|
||||||
|
"chemicalDamageMultiplier": 3.0,
|
||||||
|
"chemicalEffectivePhases": [1],
|
||||||
|
"catalystPoison": "Hg",
|
||||||
|
"catalystRegenReduction": 2.0,
|
||||||
|
"catalystArmorReduction": 0.1,
|
||||||
|
"maxCatalystStacks": 3,
|
||||||
|
"arenaRadius": 10,
|
||||||
|
"loreEntry": "The first Archon awakens. The Ouroboros — an eternal cycle of self-consumption. Its body is both catalyst and substrate, endlessly breaking itself down and rebuilding. To break its cycle is to understand catalysis itself: that which accelerates change without being consumed... until a poison finds the active site.",
|
||||||
|
"loreEntryRu": "Первый Архонт пробуждается. Уроборос — вечный цикл самопоглощения. Его тело — одновременно катализатор и субстрат, бесконечно разрушающее и восстанавливающее себя. Разорвать его цикл — значит постичь суть катализа: то, что ускоряет изменение, не расходуясь... пока яд не найдёт активный центр.",
|
||||||
|
"sporeReward": 100
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -198,5 +198,319 @@
|
|||||||
"description": "Explosive mixture of saltpeter, sulfur, and charcoal. The discovery that changed civilizations.",
|
"description": "Explosive mixture of saltpeter, sulfur, and charcoal. The discovery that changed civilizations.",
|
||||||
"descriptionRu": "Взрывчатая смесь селитры, серы и угля. Открытие, изменившее цивилизации.",
|
"descriptionRu": "Взрывчатая смесь селитры, серы и угля. Открытие, изменившее цивилизации.",
|
||||||
"gameEffects": ["explosive", "propellant", "signal_flare"]
|
"gameEffects": ["explosive", "propellant", "signal_flare"]
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": "NH3", "formula": "NH₃", "name": "Ammonia", "nameRu": "Аммиак",
|
||||||
|
"mass": 17.031, "state": "gas", "color": "#aaccff",
|
||||||
|
"properties": { "flammable": false, "toxic": true, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Pungent gas. Key to fertilizer production (Haber process). Essential biological nitrogen carrier.",
|
||||||
|
"descriptionRu": "Едкий газ. Основа производства удобрений (процесс Габера). Важнейший биологический переносчик азота.",
|
||||||
|
"gameEffects": ["fertilizer", "cleaning", "refrigerant"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "HF", "formula": "HF", "name": "Hydrofluoric Acid", "nameRu": "Плавиковая кислота",
|
||||||
|
"mass": 20.006, "state": "liquid", "color": "#ccff66",
|
||||||
|
"properties": { "flammable": false, "toxic": true, "explosive": false, "acidic": true, "basic": false, "oxidizer": false, "corrosive": true },
|
||||||
|
"description": "Etches glass! One of the most dangerous acids — penetrates skin and attacks bones.",
|
||||||
|
"descriptionRu": "Растворяет стекло! Одна из опаснейших кислот — проникает через кожу и разрушает кости.",
|
||||||
|
"gameEffects": ["glass_etching", "damage", "dissolve_stone"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "HBr", "formula": "HBr", "name": "Hydrobromic Acid", "nameRu": "Бромоводородная кислота",
|
||||||
|
"mass": 80.912, "state": "liquid", "color": "#cc6633",
|
||||||
|
"properties": { "flammable": false, "toxic": true, "explosive": false, "acidic": true, "basic": false, "oxidizer": false, "corrosive": true },
|
||||||
|
"description": "Strong acid formed from hydrogen and bromine. Stronger than HCl.",
|
||||||
|
"descriptionRu": "Сильная кислота из водорода и брома. Сильнее соляной кислоты.",
|
||||||
|
"gameEffects": ["dissolve_metal", "damage"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "HI", "formula": "HI", "name": "Hydroiodic Acid", "nameRu": "Йодоводородная кислота",
|
||||||
|
"mass": 127.912, "state": "liquid", "color": "#993399",
|
||||||
|
"properties": { "flammable": false, "toxic": true, "explosive": false, "acidic": true, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Strongest of the hydrohalic acids. Powerful reducing agent.",
|
||||||
|
"descriptionRu": "Самая сильная из галогеноводородных кислот. Мощный восстановитель.",
|
||||||
|
"gameEffects": ["reducing_agent", "dissolve_metal"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "LiOH", "formula": "LiOH", "name": "Lithium Hydroxide", "nameRu": "Гидроксид лития",
|
||||||
|
"mass": 23.948, "state": "solid", "color": "#e8e8ff",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": true, "oxidizer": false, "corrosive": true },
|
||||||
|
"description": "Lightest alkali hydroxide. Used in spacecraft CO₂ scrubbers and lithium batteries.",
|
||||||
|
"descriptionRu": "Легчайший гидроксид щелочного металла. Используется для очистки воздуха на космических кораблях и в литиевых батареях.",
|
||||||
|
"gameEffects": ["co2_scrubber", "battery_material"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "LiCl", "formula": "LiCl", "name": "Lithium Chloride", "nameRu": "Хлорид лития",
|
||||||
|
"mass": 42.394, "state": "solid", "color": "#e0e0e0",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Extremely hygroscopic salt. Absorbs moisture from air. Used as desiccant.",
|
||||||
|
"descriptionRu": "Крайне гигроскопичная соль. Поглощает влагу из воздуха. Используется как осушитель.",
|
||||||
|
"gameEffects": ["desiccant", "dehumidifier"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "TiO2", "formula": "TiO₂", "name": "Titanium Dioxide", "nameRu": "Диоксид титана",
|
||||||
|
"mass": 79.866, "state": "solid", "color": "#ffffff",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Brilliant white pigment. UV blocker. Photocatalyst that breaks down pollutants in sunlight.",
|
||||||
|
"descriptionRu": "Ярко-белый пигмент. УФ-блокатор. Фотокатализатор, расщепляющий загрязнители на свету.",
|
||||||
|
"gameEffects": ["pigment", "sun_protection", "photocatalyst"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Cr2O3", "formula": "Cr₂O₃", "name": "Chromium(III) Oxide", "nameRu": "Оксид хрома(III)",
|
||||||
|
"mass": 151.99, "state": "solid", "color": "#336633",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Deep green pigment. Extremely hard and heat-resistant. Used in armor and abrasives.",
|
||||||
|
"descriptionRu": "Насыщенный зелёный пигмент. Крайне твёрд и термостоек. Используется в броне и абразивах.",
|
||||||
|
"gameEffects": ["pigment", "armor_material", "abrasive"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "MnO2", "formula": "MnO₂", "name": "Manganese Dioxide", "nameRu": "Диоксид марганца",
|
||||||
|
"mass": 86.937, "state": "solid", "color": "#333333",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": true, "corrosive": false },
|
||||||
|
"description": "Natural catalyst and oxidizer. Key component of dry cell batteries. Accelerates many reactions.",
|
||||||
|
"descriptionRu": "Природный катализатор и окислитель. Ключевой компонент батареек. Ускоряет многие реакции.",
|
||||||
|
"gameEffects": ["catalyst", "battery_material", "oxidizer"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "CoO", "formula": "CoO", "name": "Cobalt(II) Oxide", "nameRu": "Оксид кобальта(II)",
|
||||||
|
"mass": 74.932, "state": "solid", "color": "#336699",
|
||||||
|
"properties": { "flammable": false, "toxic": true, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Deep blue compound. Used in pottery glazes since ancient Egypt. Magnetic.",
|
||||||
|
"descriptionRu": "Тёмно-синее соединение. Используется в керамических глазурях со времён Древнего Египта. Магнитно.",
|
||||||
|
"gameEffects": ["pigment", "magnetic"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "NiCl2", "formula": "NiCl₂", "name": "Nickel(II) Chloride", "nameRu": "Хлорид никеля(II)",
|
||||||
|
"mass": 129.60, "state": "solid", "color": "#00cc66",
|
||||||
|
"properties": { "flammable": false, "toxic": true, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Bright green salt. Key reagent in electroplating. Catalyst for organic reactions.",
|
||||||
|
"descriptionRu": "Ярко-зелёная соль. Ключевой реагент для гальванопокрытий. Катализатор органических реакций.",
|
||||||
|
"gameEffects": ["electroplating", "catalyst"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "As2O3", "formula": "As₂O₃", "name": "Arsenic Trioxide", "nameRu": "Оксид мышьяка(III)",
|
||||||
|
"mass": 197.841, "state": "solid", "color": "#f0f0f0",
|
||||||
|
"properties": { "flammable": false, "toxic": true, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "The 'King of Poisons'. Odorless, tasteless, lethal. Used as a rat poison for centuries.",
|
||||||
|
"descriptionRu": "«Король ядов». Без запаха, вкуса, смертелен. Веками использовался как крысиный яд.",
|
||||||
|
"gameEffects": ["deadly_poison", "rat_poison", "glass_clarifier"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "AgCl", "formula": "AgCl", "name": "Silver Chloride", "nameRu": "Хлорид серебра",
|
||||||
|
"mass": 143.321, "state": "solid", "color": "#e0e0e0",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "White solid that darkens in light (basis of photography). Antimicrobial wound dressing.",
|
||||||
|
"descriptionRu": "Белое вещество, темнеющее на свету (основа фотографии). Антимикробная раневая повязка.",
|
||||||
|
"gameEffects": ["photography", "antimicrobial", "light_sensor"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BaO", "formula": "BaO", "name": "Barium Oxide", "nameRu": "Оксид бария",
|
||||||
|
"mass": 153.326, "state": "solid", "color": "#f5f5cc",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": true, "oxidizer": false, "corrosive": true },
|
||||||
|
"description": "Powerful desiccant and getter. Reacts violently with water. Produces green flame.",
|
||||||
|
"descriptionRu": "Мощный осушитель и геттер. Бурно реагирует с водой. Даёт зелёное пламя.",
|
||||||
|
"gameEffects": ["desiccant", "green_flame"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "WO3", "formula": "WO₃", "name": "Tungsten Trioxide", "nameRu": "Оксид вольфрама(VI)",
|
||||||
|
"mass": 231.84, "state": "solid", "color": "#cccc00",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Yellow powder. Electrochromic — changes color with voltage. Smart glass technology.",
|
||||||
|
"descriptionRu": "Жёлтый порошок. Электрохромный — меняет цвет при напряжении. Технология умного стекла.",
|
||||||
|
"gameEffects": ["smart_glass", "electrochromic", "pigment"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "PbO", "formula": "PbO", "name": "Lead(II) Oxide", "nameRu": "Оксид свинца(II)",
|
||||||
|
"mass": 223.2, "state": "solid", "color": "#cc9933",
|
||||||
|
"properties": { "flammable": false, "toxic": true, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Yellow-orange powder (litharge). Used in lead-acid batteries and ancient pottery glazes.",
|
||||||
|
"descriptionRu": "Жёлто-оранжевый порошок (глёт). Используется в свинцовых аккумуляторах и древних керамических глазурях.",
|
||||||
|
"gameEffects": ["battery_material", "pigment", "radiation_shield"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "PbS", "formula": "PbS", "name": "Lead(II) Sulfide", "nameRu": "Сульфид свинца (галенит)",
|
||||||
|
"mass": 239.27, "state": "solid", "color": "#333344",
|
||||||
|
"properties": { "flammable": false, "toxic": true, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Galena — the principal ore of lead. First semiconductor discovered. Used in early radio crystal detectors.",
|
||||||
|
"descriptionRu": "Галенит — основная руда свинца. Первый обнаруженный полупроводник. Применялся в ранних радиоприёмниках.",
|
||||||
|
"gameEffects": ["semiconductor", "radio_detector", "lead_source"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Bi2O3", "formula": "Bi₂O₃", "name": "Bismuth Trioxide", "nameRu": "Оксид висмута(III)",
|
||||||
|
"mass": 465.959, "state": "solid", "color": "#ffffcc",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Yellow powder with medicinal properties. Active ingredient in stomach remedies. Low toxicity.",
|
||||||
|
"descriptionRu": "Жёлтый порошок с лечебными свойствами. Действующее вещество желудочных средств. Малотоксичен.",
|
||||||
|
"gameEffects": ["medicine", "stomach_remedy", "pigment"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "UO2", "formula": "UO₂", "name": "Uranium Dioxide", "nameRu": "Диоксид урана",
|
||||||
|
"mass": 270.028, "state": "solid", "color": "#333300",
|
||||||
|
"properties": { "flammable": false, "toxic": true, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Nuclear fuel. Incredibly dense. A single pellet contains energy equivalent to a tonne of coal.",
|
||||||
|
"descriptionRu": "Ядерное топливо. Невероятно плотный. Один стержень содержит энергию, эквивалентную тонне угля.",
|
||||||
|
"gameEffects": ["nuclear_fuel", "energy_source", "radiation"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "CH3COOH", "formula": "CH₃COOH", "name": "Acetic Acid", "nameRu": "Уксусная кислота",
|
||||||
|
"mass": 60.052, "state": "liquid", "color": "#eeeedd",
|
||||||
|
"properties": { "flammable": true, "toxic": false, "explosive": false, "acidic": true, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Vinegar. Natural product of fermentation. Solvent, preservative, and creature bait.",
|
||||||
|
"descriptionRu": "Уксус. Природный продукт брожения. Растворитель, консервант и приманка для существ.",
|
||||||
|
"gameEffects": ["solvent", "preservative", "creature_bait", "descaler"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "C6H12O6", "formula": "C₆H₁₂O₆", "name": "Glucose", "nameRu": "Глюкоза",
|
||||||
|
"mass": 180.156, "state": "solid", "color": "#ffffee",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Simple sugar. Universal biological fuel. Feed to microorganisms or use as fast energy source.",
|
||||||
|
"descriptionRu": "Простой сахар. Универсальное биологическое топливо. Корм для микроорганизмов или быстрый источник энергии.",
|
||||||
|
"gameEffects": ["energy_food", "microorganism_feed", "fermentation_base"]
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": "H2S", "formula": "H₂S", "name": "Hydrogen Sulfide", "nameRu": "Сероводород",
|
||||||
|
"mass": 34.08, "state": "gas", "color": "#cccc66",
|
||||||
|
"properties": { "flammable": true, "toxic": true, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Rotten egg smell. Toxic in high concentrations. Natural hot spring emission.",
|
||||||
|
"descriptionRu": "Запах тухлых яиц. Токсичен в высоких концентрациях. Газ горячих источников.",
|
||||||
|
"gameEffects": ["poison_gas", "indicator", "hot_spring"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "H2SO4", "formula": "H₂SO₄", "name": "Sulfuric Acid", "nameRu": "Серная кислота",
|
||||||
|
"mass": 98.079, "state": "liquid", "color": "#ffee00",
|
||||||
|
"properties": { "flammable": false, "toxic": true, "explosive": false, "acidic": true, "basic": false, "oxidizer": true, "corrosive": true },
|
||||||
|
"description": "King of chemicals. Most produced chemical worldwide. Violently exothermic with water.",
|
||||||
|
"descriptionRu": "Царь химикатов. Самое производимое химическое вещество в мире. Бурно реагирует с водой.",
|
||||||
|
"gameEffects": ["dissolve_metal", "dissolve_organic", "battery_acid", "damage"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "HNO3", "formula": "HNO₃", "name": "Nitric Acid", "nameRu": "Азотная кислота",
|
||||||
|
"mass": 63.012, "state": "liquid", "color": "#ff6600",
|
||||||
|
"properties": { "flammable": false, "toxic": true, "explosive": false, "acidic": true, "basic": false, "oxidizer": true, "corrosive": true },
|
||||||
|
"description": "Fuming corrosive acid. Dissolves most metals. Component of aqua regia (with HCl) to dissolve gold.",
|
||||||
|
"descriptionRu": "Дымящая кислота. Растворяет большинство металлов. Компонент царской водки (с HCl) для растворения золота.",
|
||||||
|
"gameEffects": ["dissolve_metal", "oxidizer", "damage", "aqua_regia_component"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "CuO", "formula": "CuO", "name": "Copper(II) Oxide", "nameRu": "Оксид меди(II)",
|
||||||
|
"mass": 79.545, "state": "solid", "color": "#1a1a1a",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Black powder. Reducible by carbon or hydrogen — ancient copper smelting.",
|
||||||
|
"descriptionRu": "Чёрный порошок. Восстанавливается углеродом или водородом — древняя медеплавка.",
|
||||||
|
"gameEffects": ["pigment", "smelting_ore", "catalyst"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "NaF", "formula": "NaF", "name": "Sodium Fluoride", "nameRu": "Фторид натрия",
|
||||||
|
"mass": 41.988, "state": "solid", "color": "#f0f0f0",
|
||||||
|
"properties": { "flammable": false, "toxic": true, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Active ingredient in toothpaste. Strengthens tooth enamel. Toxic in large doses.",
|
||||||
|
"descriptionRu": "Активный компонент зубной пасты. Укрепляет зубную эмаль. Токсичен в больших дозах.",
|
||||||
|
"gameEffects": ["dental_protection", "water_treatment"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "NaBr", "formula": "NaBr", "name": "Sodium Bromide", "nameRu": "Бромид натрия",
|
||||||
|
"mass": 102.894, "state": "solid", "color": "#e8e8e8",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "White crystalline salt. Historical sedative and anticonvulsant. Photography reagent.",
|
||||||
|
"descriptionRu": "Белая кристаллическая соль. Исторический седативный и противосудорожный препарат.",
|
||||||
|
"gameEffects": ["sedative", "photography"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KF", "formula": "KF", "name": "Potassium Fluoride", "nameRu": "Фторид калия",
|
||||||
|
"mass": 58.097, "state": "solid", "color": "#e0e0e0",
|
||||||
|
"properties": { "flammable": false, "toxic": true, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": true },
|
||||||
|
"description": "Strong fluorinating agent. Used in organic synthesis. Corrosive.",
|
||||||
|
"descriptionRu": "Сильный фторирующий агент. Используется в органическом синтезе. Едкий.",
|
||||||
|
"gameEffects": ["fluorinating_agent"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KBr", "formula": "KBr", "name": "Potassium Bromide", "nameRu": "Бромид калия",
|
||||||
|
"mass": 119.002, "state": "solid", "color": "#e0e0e0",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Photography essential. Historical anticonvulsant medicine. Transparent to infrared.",
|
||||||
|
"descriptionRu": "Необходим для фотографии. Исторический противосудорожный препарат. Прозрачен для инфракрасного.",
|
||||||
|
"gameEffects": ["photography", "medicine", "optics"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "AgBr", "formula": "AgBr", "name": "Silver Bromide", "nameRu": "Бромид серебра",
|
||||||
|
"mass": 187.772, "state": "solid", "color": "#e8e8cc",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Pale yellow, darkens in light. The basis of photographic film. More light-sensitive than AgCl.",
|
||||||
|
"descriptionRu": "Бледно-жёлтый, темнеет на свету. Основа фотоплёнки. Светочувствительнее AgCl.",
|
||||||
|
"gameEffects": ["photography", "light_sensor"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "NH4Cl", "formula": "NH₄Cl", "name": "Ammonium Chloride", "nameRu": "Хлорид аммония",
|
||||||
|
"mass": 53.491, "state": "solid", "color": "#f5f5f5",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Sal ammoniac. White smoke when heated. Used in batteries, flux, and cough medicine.",
|
||||||
|
"descriptionRu": "Нашатырь. Белый дым при нагревании. Используется в батарейках, флюсе и от кашля.",
|
||||||
|
"gameEffects": ["flux", "battery_material", "smoke_screen"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "MgCl2", "formula": "MgCl₂", "name": "Magnesium Chloride", "nameRu": "Хлорид магния",
|
||||||
|
"mass": 95.211, "state": "solid", "color": "#e0e0e0",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "De-icing salt. Dust suppressant. Source of magnesium metal via electrolysis.",
|
||||||
|
"descriptionRu": "Противогололёдная соль. Подавитель пыли. Источник металлического магния через электролиз.",
|
||||||
|
"gameEffects": ["de_icing", "dust_control", "magnesium_source"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "CaCl2", "formula": "CaCl₂", "name": "Calcium Chloride", "nameRu": "Хлорид кальция",
|
||||||
|
"mass": 110.984, "state": "solid", "color": "#f0f0f0",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Powerful desiccant and de-icer. Exothermic when dissolved — can melt ice below -50°C.",
|
||||||
|
"descriptionRu": "Мощный осушитель и антигололёдный реагент. Экзотермичен при растворении — плавит лёд ниже -50°C.",
|
||||||
|
"gameEffects": ["desiccant", "de_icing", "heat_source"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BaCl2", "formula": "BaCl₂", "name": "Barium Chloride", "nameRu": "Хлорид бария",
|
||||||
|
"mass": 208.233, "state": "solid", "color": "#f0f0f0",
|
||||||
|
"properties": { "flammable": false, "toxic": true, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Toxic barium salt. Burns with characteristic green flame. Chemical analysis reagent.",
|
||||||
|
"descriptionRu": "Токсичная соль бария. Горит характерным зелёным пламенем. Реагент для химического анализа.",
|
||||||
|
"gameEffects": ["green_flame", "analysis_reagent"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "FeCl3", "formula": "FeCl₃", "name": "Iron(III) Chloride", "nameRu": "Хлорид железа(III)",
|
||||||
|
"mass": 162.204, "state": "solid", "color": "#663300",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": true },
|
||||||
|
"description": "Dark brown, corrosive. Etches copper (circuit boards). Water purification agent.",
|
||||||
|
"descriptionRu": "Тёмно-коричневый, едкий. Травит медь (печатные платы). Реагент для очистки воды.",
|
||||||
|
"gameEffects": ["copper_etching", "water_purification", "coagulant"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "CuCl2", "formula": "CuCl₂", "name": "Copper(II) Chloride", "nameRu": "Хлорид меди(II)",
|
||||||
|
"mass": 134.452, "state": "solid", "color": "#006633",
|
||||||
|
"properties": { "flammable": false, "toxic": true, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Bright green crystals. Turns blue in water. Catalyst. Gives green-blue flame.",
|
||||||
|
"descriptionRu": "Ярко-зелёные кристаллы. Синеет в воде. Катализатор. Даёт зелёно-голубое пламя.",
|
||||||
|
"gameEffects": ["catalyst", "pigment", "blue_green_flame"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "FeCl2", "formula": "FeCl₂", "name": "Iron(II) Chloride", "nameRu": "Хлорид железа(II)",
|
||||||
|
"mass": 126.751, "state": "solid", "color": "#669966",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Greenish solid. Product of iron dissolved in HCl. Reducing agent.",
|
||||||
|
"descriptionRu": "Зеленоватое твёрдое вещество. Продукт растворения железа в HCl. Восстановитель.",
|
||||||
|
"gameEffects": ["reducing_agent", "water_treatment"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ZnCl2", "formula": "ZnCl₂", "name": "Zinc Chloride", "nameRu": "Хлорид цинка",
|
||||||
|
"mass": 136.286, "state": "solid", "color": "#e0e0e0",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": true },
|
||||||
|
"description": "Powerful flux for soldering. Dehydrating agent. Used in fireproofing fabric.",
|
||||||
|
"descriptionRu": "Мощный флюс для пайки. Обезвоживающий агент. Огнезащита тканей.",
|
||||||
|
"gameEffects": ["flux", "desiccant", "fireproofing"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Na2O", "formula": "Na₂O", "name": "Sodium Oxide", "nameRu": "Оксид натрия",
|
||||||
|
"mass": 61.979, "state": "solid", "color": "#f5f5f5",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": true, "oxidizer": false, "corrosive": true },
|
||||||
|
"description": "White powder. Reacts violently with water to form NaOH. Key component in glass making.",
|
||||||
|
"descriptionRu": "Белый порошок. Бурно реагирует с водой, образуя NaOH. Ключевой компонент стекловарения.",
|
||||||
|
"gameEffects": ["glass_making", "caustic"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
335
src/data/creatures.json
Normal file
335
src/data/creatures.json
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "crystallid",
|
||||||
|
"name": "Crystallid",
|
||||||
|
"nameRu": "Кристаллид",
|
||||||
|
"speciesId": 0,
|
||||||
|
"biome": "catalytic-wastes",
|
||||||
|
"description": "Slow, armored creature that feeds on mineral deposits. Its silicon-rich body refracts light into prismatic patterns. When killed, it shatters into valuable crystalline fragments.",
|
||||||
|
"descriptionRu": "Медленное бронированное существо, питающееся минеральными отложениями. Его кремниевое тело преломляет свет в призматические узоры. При гибели рассыпается на ценные кристаллические осколки.",
|
||||||
|
"color": "#88ccff",
|
||||||
|
"radius": 10,
|
||||||
|
"radiusYouth": 6,
|
||||||
|
"health": 120,
|
||||||
|
"speed": 30,
|
||||||
|
"damage": 8,
|
||||||
|
"armor": 0.3,
|
||||||
|
"diet": "mineral",
|
||||||
|
"dietTiles": ["mineral-vein", "crystal-formation"],
|
||||||
|
"excretionElement": 14,
|
||||||
|
"energyMax": 100,
|
||||||
|
"energyPerFeed": 25,
|
||||||
|
"energyDrainPerSecond": 1.5,
|
||||||
|
"hungerThreshold": 0.4,
|
||||||
|
"aggressionRadius": 0,
|
||||||
|
"fleeRadius": 80,
|
||||||
|
"wanderRadius": 200,
|
||||||
|
"attackRange": 20,
|
||||||
|
"attackCooldown": 2000,
|
||||||
|
"eggDuration": 8000,
|
||||||
|
"youthDuration": 15000,
|
||||||
|
"matureDuration": 60000,
|
||||||
|
"agingDuration": 20000,
|
||||||
|
"reproductionEnergy": 60,
|
||||||
|
"offspringCount": 2,
|
||||||
|
"maxPopulation": 12,
|
||||||
|
"spawnWeight": 0.4,
|
||||||
|
"preferredTiles": ["ground", "scorched-earth"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "acidophile",
|
||||||
|
"name": "Acidophile",
|
||||||
|
"nameRu": "Ацидофил",
|
||||||
|
"speciesId": 1,
|
||||||
|
"biome": "catalytic-wastes",
|
||||||
|
"description": "Mid-sized creature that thrives in acidic environments. Consumes mineral deposits and excretes hydrochloric acid, gradually reshaping the landscape. Territorial but not predatory.",
|
||||||
|
"descriptionRu": "Среднеразмерное существо, процветающее в кислотной среде. Поглощает минералы и выделяет соляную кислоту, постепенно изменяя ландшафт. Территориальное, но не хищное.",
|
||||||
|
"color": "#44ff44",
|
||||||
|
"radius": 8,
|
||||||
|
"radiusYouth": 5,
|
||||||
|
"health": 80,
|
||||||
|
"speed": 50,
|
||||||
|
"damage": 12,
|
||||||
|
"armor": 0.1,
|
||||||
|
"diet": "mineral",
|
||||||
|
"dietTiles": ["mineral-vein", "geyser"],
|
||||||
|
"excretionElement": 17,
|
||||||
|
"energyMax": 80,
|
||||||
|
"energyPerFeed": 20,
|
||||||
|
"energyDrainPerSecond": 2.0,
|
||||||
|
"hungerThreshold": 0.5,
|
||||||
|
"aggressionRadius": 100,
|
||||||
|
"fleeRadius": 120,
|
||||||
|
"wanderRadius": 250,
|
||||||
|
"attackRange": 24,
|
||||||
|
"attackCooldown": 1500,
|
||||||
|
"eggDuration": 6000,
|
||||||
|
"youthDuration": 12000,
|
||||||
|
"matureDuration": 45000,
|
||||||
|
"agingDuration": 15000,
|
||||||
|
"reproductionEnergy": 50,
|
||||||
|
"offspringCount": 3,
|
||||||
|
"maxPopulation": 15,
|
||||||
|
"spawnWeight": 0.35,
|
||||||
|
"preferredTiles": ["acid-shallow", "ground"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "reagent",
|
||||||
|
"name": "Reagent",
|
||||||
|
"nameRu": "Реагент",
|
||||||
|
"speciesId": 2,
|
||||||
|
"biome": "catalytic-wastes",
|
||||||
|
"description": "Fast, aggressive predator that hunts in pairs. Contains unstable chemical compounds — when two Reagents collide, they trigger an exothermic reaction. Feeds on other creatures.",
|
||||||
|
"descriptionRu": "Быстрый агрессивный хищник, охотящийся парами. Содержит нестабильные химические соединения — при столкновении двух Реагентов происходит экзотермическая реакция. Питается другими существами.",
|
||||||
|
"color": "#ff4444",
|
||||||
|
"radius": 7,
|
||||||
|
"radiusYouth": 4,
|
||||||
|
"health": 60,
|
||||||
|
"speed": 80,
|
||||||
|
"damage": 20,
|
||||||
|
"armor": 0.0,
|
||||||
|
"diet": "creature",
|
||||||
|
"dietTiles": [],
|
||||||
|
"excretionElement": 0,
|
||||||
|
"energyMax": 60,
|
||||||
|
"energyPerFeed": 30,
|
||||||
|
"energyDrainPerSecond": 3.0,
|
||||||
|
"hungerThreshold": 0.6,
|
||||||
|
"aggressionRadius": 150,
|
||||||
|
"fleeRadius": 60,
|
||||||
|
"wanderRadius": 300,
|
||||||
|
"attackRange": 18,
|
||||||
|
"attackCooldown": 1000,
|
||||||
|
"eggDuration": 5000,
|
||||||
|
"youthDuration": 10000,
|
||||||
|
"matureDuration": 35000,
|
||||||
|
"agingDuration": 12000,
|
||||||
|
"reproductionEnergy": 40,
|
||||||
|
"offspringCount": 1,
|
||||||
|
"maxPopulation": 8,
|
||||||
|
"spawnWeight": 0.25,
|
||||||
|
"preferredTiles": ["scorched-earth", "ground"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pendulum",
|
||||||
|
"name": "Pendulum",
|
||||||
|
"nameRu": "Маятник",
|
||||||
|
"speciesId": 3,
|
||||||
|
"biome": "kinetic-mountains",
|
||||||
|
"description": "Flying creature with strictly periodic motion. Swings back and forth on invisible pivot points. Can be used as a clock or platform. Docile unless struck mid-swing.",
|
||||||
|
"descriptionRu": "Летающее существо со строго периодичным движением. Качается взад-вперёд на невидимых опорных точках. Можно использовать как часы или платформу. Мирное, если не ударить в полёте.",
|
||||||
|
"color": "#77aadd",
|
||||||
|
"radius": 9,
|
||||||
|
"radiusYouth": 5,
|
||||||
|
"health": 90,
|
||||||
|
"speed": 60,
|
||||||
|
"damage": 10,
|
||||||
|
"armor": 0.15,
|
||||||
|
"diet": "mineral",
|
||||||
|
"dietTiles": ["ore-deposit", "gear-floor"],
|
||||||
|
"excretionElement": 26,
|
||||||
|
"energyMax": 90,
|
||||||
|
"energyPerFeed": 22,
|
||||||
|
"energyDrainPerSecond": 1.8,
|
||||||
|
"hungerThreshold": 0.45,
|
||||||
|
"aggressionRadius": 0,
|
||||||
|
"fleeRadius": 100,
|
||||||
|
"wanderRadius": 220,
|
||||||
|
"attackRange": 22,
|
||||||
|
"attackCooldown": 1800,
|
||||||
|
"eggDuration": 7000,
|
||||||
|
"youthDuration": 14000,
|
||||||
|
"matureDuration": 55000,
|
||||||
|
"agingDuration": 18000,
|
||||||
|
"reproductionEnergy": 55,
|
||||||
|
"offspringCount": 2,
|
||||||
|
"maxPopulation": 10,
|
||||||
|
"spawnWeight": 0.4,
|
||||||
|
"preferredTiles": ["bare-rock", "gear-floor"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mechanoid",
|
||||||
|
"name": "Mechanoid",
|
||||||
|
"nameRu": "Механоид",
|
||||||
|
"speciesId": 4,
|
||||||
|
"biome": "kinetic-mountains",
|
||||||
|
"description": "Half-living automaton. Can be repaired, reprogrammed, or scavenged for metal parts. Patrols ancient machinery and attacks intruders near gear mechanisms.",
|
||||||
|
"descriptionRu": "Полуживой автомат. Можно починить, перепрограммировать или разобрать на металлические детали. Патрулирует древние механизмы и атакует чужаков у шестерёнок.",
|
||||||
|
"color": "#aaaacc",
|
||||||
|
"radius": 11,
|
||||||
|
"radiusYouth": 7,
|
||||||
|
"health": 150,
|
||||||
|
"speed": 35,
|
||||||
|
"damage": 18,
|
||||||
|
"armor": 0.4,
|
||||||
|
"diet": "mineral",
|
||||||
|
"dietTiles": ["ore-deposit"],
|
||||||
|
"excretionElement": 29,
|
||||||
|
"energyMax": 120,
|
||||||
|
"energyPerFeed": 30,
|
||||||
|
"energyDrainPerSecond": 1.0,
|
||||||
|
"hungerThreshold": 0.3,
|
||||||
|
"aggressionRadius": 120,
|
||||||
|
"fleeRadius": 0,
|
||||||
|
"wanderRadius": 150,
|
||||||
|
"attackRange": 26,
|
||||||
|
"attackCooldown": 2200,
|
||||||
|
"eggDuration": 10000,
|
||||||
|
"youthDuration": 18000,
|
||||||
|
"matureDuration": 80000,
|
||||||
|
"agingDuration": 25000,
|
||||||
|
"reproductionEnergy": 70,
|
||||||
|
"offspringCount": 1,
|
||||||
|
"maxPopulation": 8,
|
||||||
|
"spawnWeight": 0.3,
|
||||||
|
"preferredTiles": ["gear-floor", "iron-ridge"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "resonator",
|
||||||
|
"name": "Resonator",
|
||||||
|
"nameRu": "Резонатор",
|
||||||
|
"speciesId": 5,
|
||||||
|
"biome": "kinetic-mountains",
|
||||||
|
"description": "Vibrates at fixed frequencies. Dangerous if its frequency matches your gear — resonance shatters equipment. Can be used to break barriers or stun other creatures.",
|
||||||
|
"descriptionRu": "Вибрирует на фиксированных частотах. Опасен, если его частота совпадает с вашим снаряжением — резонанс разрушает оборудование. Может ломать барьеры или оглушать существ.",
|
||||||
|
"color": "#ff88ff",
|
||||||
|
"radius": 7,
|
||||||
|
"radiusYouth": 4,
|
||||||
|
"health": 50,
|
||||||
|
"speed": 70,
|
||||||
|
"damage": 25,
|
||||||
|
"armor": 0.0,
|
||||||
|
"diet": "creature",
|
||||||
|
"dietTiles": [],
|
||||||
|
"excretionElement": 0,
|
||||||
|
"energyMax": 55,
|
||||||
|
"energyPerFeed": 25,
|
||||||
|
"energyDrainPerSecond": 2.8,
|
||||||
|
"hungerThreshold": 0.55,
|
||||||
|
"aggressionRadius": 130,
|
||||||
|
"fleeRadius": 70,
|
||||||
|
"wanderRadius": 280,
|
||||||
|
"attackRange": 20,
|
||||||
|
"attackCooldown": 1200,
|
||||||
|
"eggDuration": 5000,
|
||||||
|
"youthDuration": 10000,
|
||||||
|
"matureDuration": 40000,
|
||||||
|
"agingDuration": 13000,
|
||||||
|
"reproductionEnergy": 35,
|
||||||
|
"offspringCount": 1,
|
||||||
|
"maxPopulation": 6,
|
||||||
|
"spawnWeight": 0.3,
|
||||||
|
"preferredTiles": ["magnetic-field", "bare-rock"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "symbiote",
|
||||||
|
"name": "Symbiote",
|
||||||
|
"nameRu": "Симбионт",
|
||||||
|
"speciesId": 6,
|
||||||
|
"biome": "verdant-forests",
|
||||||
|
"description": "Always appears in bonded pairs. Separating them weakens both — one may die without the other. Feeds on forest floor decomposition. Peaceful but resilient in pairs.",
|
||||||
|
"descriptionRu": "Всегда появляется связанными парами. Разделение ослабляет обоих — один может погибнуть без другого. Питается лесной подстилкой. Мирные, но в паре стойкие.",
|
||||||
|
"color": "#44ddaa",
|
||||||
|
"radius": 8,
|
||||||
|
"radiusYouth": 5,
|
||||||
|
"health": 70,
|
||||||
|
"speed": 45,
|
||||||
|
"damage": 6,
|
||||||
|
"armor": 0.2,
|
||||||
|
"diet": "mineral",
|
||||||
|
"dietTiles": ["herb-patch", "mycelium-carpet"],
|
||||||
|
"excretionElement": 7,
|
||||||
|
"energyMax": 85,
|
||||||
|
"energyPerFeed": 20,
|
||||||
|
"energyDrainPerSecond": 1.6,
|
||||||
|
"hungerThreshold": 0.4,
|
||||||
|
"aggressionRadius": 0,
|
||||||
|
"fleeRadius": 90,
|
||||||
|
"wanderRadius": 180,
|
||||||
|
"attackRange": 18,
|
||||||
|
"attackCooldown": 2000,
|
||||||
|
"eggDuration": 7000,
|
||||||
|
"youthDuration": 13000,
|
||||||
|
"matureDuration": 50000,
|
||||||
|
"agingDuration": 17000,
|
||||||
|
"reproductionEnergy": 45,
|
||||||
|
"offspringCount": 2,
|
||||||
|
"maxPopulation": 14,
|
||||||
|
"spawnWeight": 0.4,
|
||||||
|
"preferredTiles": ["forest-floor", "dense-undergrowth"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mimic",
|
||||||
|
"name": "Mimic",
|
||||||
|
"nameRu": "Мимикр",
|
||||||
|
"speciesId": 7,
|
||||||
|
"biome": "verdant-forests",
|
||||||
|
"description": "Master of disguise. Can appear as a plant, resource, or even a fungal node. Only reveals itself when approached too closely. Ambush predator with potent venom.",
|
||||||
|
"descriptionRu": "Мастер маскировки. Может выглядеть как растение, ресурс или грибной узел. Раскрывает себя только при близком приближении. Засадный хищник с мощным ядом.",
|
||||||
|
"color": "#33cc33",
|
||||||
|
"radius": 8,
|
||||||
|
"radiusYouth": 5,
|
||||||
|
"health": 65,
|
||||||
|
"speed": 55,
|
||||||
|
"damage": 22,
|
||||||
|
"armor": 0.05,
|
||||||
|
"diet": "creature",
|
||||||
|
"dietTiles": [],
|
||||||
|
"excretionElement": 0,
|
||||||
|
"energyMax": 65,
|
||||||
|
"energyPerFeed": 28,
|
||||||
|
"energyDrainPerSecond": 2.2,
|
||||||
|
"hungerThreshold": 0.5,
|
||||||
|
"aggressionRadius": 60,
|
||||||
|
"fleeRadius": 80,
|
||||||
|
"wanderRadius": 200,
|
||||||
|
"attackRange": 16,
|
||||||
|
"attackCooldown": 1400,
|
||||||
|
"eggDuration": 6000,
|
||||||
|
"youthDuration": 11000,
|
||||||
|
"matureDuration": 42000,
|
||||||
|
"agingDuration": 14000,
|
||||||
|
"reproductionEnergy": 42,
|
||||||
|
"offspringCount": 1,
|
||||||
|
"maxPopulation": 7,
|
||||||
|
"spawnWeight": 0.25,
|
||||||
|
"preferredTiles": ["forest-floor", "mycelium-carpet"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "spore-bearer",
|
||||||
|
"name": "Spore-bearer",
|
||||||
|
"nameRu": "Споровик",
|
||||||
|
"speciesId": 8,
|
||||||
|
"biome": "verdant-forests",
|
||||||
|
"description": "Fungal creature, avatar of the Mycelium. Peaceful and slow. If harmed, the entire forest turns hostile. Deposits knowledge into fungal nodes when passing near them.",
|
||||||
|
"descriptionRu": "Грибное существо, аватар Мицелия. Мирное и медленное. Если обидеть — весь лес становится враждебным. Откладывает знания в грибные узлы, проходя мимо них.",
|
||||||
|
"color": "#9966cc",
|
||||||
|
"radius": 12,
|
||||||
|
"radiusYouth": 7,
|
||||||
|
"health": 200,
|
||||||
|
"speed": 20,
|
||||||
|
"damage": 5,
|
||||||
|
"armor": 0.25,
|
||||||
|
"diet": "mineral",
|
||||||
|
"dietTiles": ["mycelium-carpet", "herb-patch"],
|
||||||
|
"excretionElement": 15,
|
||||||
|
"energyMax": 150,
|
||||||
|
"energyPerFeed": 35,
|
||||||
|
"energyDrainPerSecond": 0.8,
|
||||||
|
"hungerThreshold": 0.3,
|
||||||
|
"aggressionRadius": 0,
|
||||||
|
"fleeRadius": 60,
|
||||||
|
"wanderRadius": 160,
|
||||||
|
"attackRange": 24,
|
||||||
|
"attackCooldown": 3000,
|
||||||
|
"eggDuration": 12000,
|
||||||
|
"youthDuration": 20000,
|
||||||
|
"matureDuration": 90000,
|
||||||
|
"agingDuration": 30000,
|
||||||
|
"reproductionEnergy": 80,
|
||||||
|
"offspringCount": 1,
|
||||||
|
"maxPopulation": 5,
|
||||||
|
"spawnWeight": 0.35,
|
||||||
|
"preferredTiles": ["mycelium-carpet", "forest-floor"]
|
||||||
|
}
|
||||||
|
]
|
||||||
97
src/data/cycle-narrative.json
Normal file
97
src/data/cycle-narrative.json
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
{
|
||||||
|
"themes": {
|
||||||
|
"awakening": {
|
||||||
|
"name": "Awakening",
|
||||||
|
"nameRu": "Пробуждение",
|
||||||
|
"description": "You are learning the world. Everything is new, mysterious, dangerous.",
|
||||||
|
"descriptionRu": "Ты познаёшь мир. Всё ново, загадочно, опасно.",
|
||||||
|
"cradleQuote": "...the first breath of a new cycle...",
|
||||||
|
"cradleQuoteRu": "...первый вдох нового цикла...",
|
||||||
|
"loreFrag": [
|
||||||
|
{ "text": "The world breathes. You are its newest thought.", "textRu": "Мир дышит. Ты — его новейшая мысль." },
|
||||||
|
{ "text": "Each atom you touch remembers being touched before.", "textRu": "Каждый атом, которого ты касаешься, помнит прежние прикосновения." },
|
||||||
|
{ "text": "The cycle begins. As it always does. As it always will.", "textRu": "Цикл начинается. Как всегда. Как будет всегда." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"doubt": {
|
||||||
|
"name": "Doubt",
|
||||||
|
"nameRu": "Сомнение",
|
||||||
|
"description": "You find traces of past Adepts. Were they... you?",
|
||||||
|
"descriptionRu": "Ты находишь следы прошлых Адептов. Были ли они... тобой?",
|
||||||
|
"cradleQuote": "...something stirs in the mycelium. Familiar...",
|
||||||
|
"cradleQuoteRu": "...что-то шевелится в мицелии. Знакомое...",
|
||||||
|
"loreFrag": [
|
||||||
|
{ "text": "Ruins that look like they were built by your hands.", "textRu": "Руины, словно построенные твоими руками." },
|
||||||
|
{ "text": "The handwriting in this journal... is it yours?", "textRu": "Почерк в этом дневнике... он твой?" },
|
||||||
|
{ "text": "I have been here before. Or someone exactly like me.", "textRu": "Я уже был здесь. Или кто-то точно такой же, как я." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"realization": {
|
||||||
|
"name": "Realization",
|
||||||
|
"nameRu": "Осознание",
|
||||||
|
"description": "You begin to understand the nature of cycles.",
|
||||||
|
"descriptionRu": "Ты начинаешь понимать природу циклов.",
|
||||||
|
"cradleQuote": "...the pattern becomes visible...",
|
||||||
|
"cradleQuoteRu": "...паттерн становится видимым...",
|
||||||
|
"loreFrag": [
|
||||||
|
{ "text": "The cycle is not a prison. It is a spiral.", "textRu": "Цикл — не тюрьма. Это спираль." },
|
||||||
|
{ "text": "Each death is a transformation, not an ending.", "textRu": "Каждая смерть — трансформация, не конец." },
|
||||||
|
{ "text": "The Mycelium knew all along. It was waiting for you to see.", "textRu": "Мицелий знал с самого начала. Он ждал, пока ты увидишь." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"attempt": {
|
||||||
|
"name": "Attempt",
|
||||||
|
"nameRu": "Попытка",
|
||||||
|
"description": "First attempts to transcend the cycle.",
|
||||||
|
"descriptionRu": "Первые попытки трансцендировать цикл.",
|
||||||
|
"cradleQuote": "...can the cycle be broken? Or only understood?...",
|
||||||
|
"cradleQuoteRu": "...можно ли разорвать цикл? Или только понять?...",
|
||||||
|
"loreFrag": [
|
||||||
|
{ "text": "The Synthetics tried to break free. They shattered reality instead.", "textRu": "Синтетики пытались вырваться. Вместо этого разбили реальность." },
|
||||||
|
{ "text": "To transcend is not to escape. It is to encompass.", "textRu": "Трансцендировать — не значит сбежать. Это значит объять." },
|
||||||
|
{ "text": "The harder you push against the cycle, the tighter it holds.", "textRu": "Чем сильнее давишь на цикл, тем крепче он держит." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"acceptance": {
|
||||||
|
"name": "Acceptance",
|
||||||
|
"nameRu": "Принятие",
|
||||||
|
"description": "The cycle is not a prison but a form of being.",
|
||||||
|
"descriptionRu": "Цикл — не тюрьма, а форма бытия.",
|
||||||
|
"cradleQuote": "...you are not stuck IN the cycle. You ARE the cycle...",
|
||||||
|
"cradleQuoteRu": "...ты не застрял В цикле. Ты И ЕСТЬ цикл...",
|
||||||
|
"loreFrag": [
|
||||||
|
{ "text": "An atom does not resent its orbit. A wave does not fight its shore.", "textRu": "Атом не злится на свою орбиту. Волна не борется со своим берегом." },
|
||||||
|
{ "text": "The only way out is through. And through. And through.", "textRu": "Единственный выход — насквозь. И насквозь. И насквозь." },
|
||||||
|
{ "text": "Every cycle slightly different. Every cycle slightly closer.", "textRu": "Каждый цикл чуть другой. Каждый цикл чуть ближе." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"synthesis": {
|
||||||
|
"name": "Synthesis",
|
||||||
|
"nameRu": "Синтез",
|
||||||
|
"description": "All knowledge unifies. The cycle does not break — it transcends.",
|
||||||
|
"descriptionRu": "Всё знание объединяется. Цикл не ломается — трансцендирует.",
|
||||||
|
"cradleQuote": "...when all fragments connect, something new emerges...",
|
||||||
|
"cradleQuoteRu": "...когда все фрагменты соединяются, рождается новое...",
|
||||||
|
"loreFrag": [
|
||||||
|
{ "text": "Synthesis is not creation. It is transformation.", "textRu": "Синтез — не создание. Это трансформация." },
|
||||||
|
{ "text": "You have become what the Synthetics could not: a living cycle.", "textRu": "Ты стал тем, чем Синтетики не смогли: живым циклом." },
|
||||||
|
{ "text": "The Law of Synthesis: when all fragments connect, the cycle transcends.", "textRu": "Закон Синтеза: когда все фрагменты знания соединяются, цикл трансцендирует." }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"renewalMessages": [
|
||||||
|
{ "text": "The Great Renewal washes over the world...", "textRu": "Великое Обновление окатывает мир..." },
|
||||||
|
{ "text": "Seven lives. Seven deaths. The cycle turns.", "textRu": "Семь жизней. Семь смертей. Цикл поворачивается." },
|
||||||
|
{ "text": "The Mycelium pulses. A new age begins.", "textRu": "Мицелий пульсирует. Начинается новая эпоха." }
|
||||||
|
],
|
||||||
|
"traceMessages": {
|
||||||
|
"death_site": [
|
||||||
|
{ "text": "Here an adept fell... elements returning to the earth.", "textRu": "Здесь пал адепт... элементы возвращаются в землю." },
|
||||||
|
{ "text": "A mark of dissolution. Someone ended their cycle here.", "textRu": "Знак растворения. Кто-то завершил свой цикл здесь." }
|
||||||
|
],
|
||||||
|
"discovery_site": [
|
||||||
|
{ "text": "Traces of experimentation linger in this place.", "textRu": "Следы экспериментов витают в этом месте." },
|
||||||
|
{ "text": "The soil remembers a reaction that happened here.", "textRu": "Почва помнит реакцию, произошедшую здесь." }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,32 @@
|
|||||||
"description": "Noble gas. Completely inert — refuses to react with anything. Lighter than air.",
|
"description": "Noble gas. Completely inert — refuses to react with anything. Lighter than air.",
|
||||||
"descriptionRu": "Благородный газ. Абсолютно инертен — не реагирует ни с чем. Легче воздуха."
|
"descriptionRu": "Благородный газ. Абсолютно инертен — не реагирует ни с чем. Легче воздуха."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"symbol": "Li",
|
||||||
|
"name": "Lithium",
|
||||||
|
"nameRu": "Литий",
|
||||||
|
"atomicNumber": 3,
|
||||||
|
"atomicMass": 6.941,
|
||||||
|
"electronegativity": 0.98,
|
||||||
|
"category": "alkali-metal",
|
||||||
|
"state": "solid",
|
||||||
|
"color": "#cc80ff",
|
||||||
|
"description": "Lightest metal. Soft enough to cut with a knife. Key component of modern batteries. Burns crimson red.",
|
||||||
|
"descriptionRu": "Легчайший металл. Настолько мягкий, что режется ножом. Ключевой компонент современных аккумуляторов. Горит малиновым пламенем."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "B",
|
||||||
|
"name": "Boron",
|
||||||
|
"nameRu": "Бор",
|
||||||
|
"atomicNumber": 5,
|
||||||
|
"atomicMass": 10.811,
|
||||||
|
"electronegativity": 2.04,
|
||||||
|
"category": "metalloid",
|
||||||
|
"state": "solid",
|
||||||
|
"color": "#ffb5b5",
|
||||||
|
"description": "Hard metalloid. Essential for borosilicate glass (heat-resistant). Boron compounds are used as neutron absorbers.",
|
||||||
|
"descriptionRu": "Твёрдый металлоид. Основа боросиликатного стекла (термостойкого). Соединения бора поглощают нейтроны."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"symbol": "C",
|
"symbol": "C",
|
||||||
"name": "Carbon",
|
"name": "Carbon",
|
||||||
@@ -64,6 +90,32 @@
|
|||||||
"description": "Essential for combustion and respiration. Highly reactive oxidizer. 21% of air.",
|
"description": "Essential for combustion and respiration. Highly reactive oxidizer. 21% of air.",
|
||||||
"descriptionRu": "Необходим для горения и дыхания. Сильный окислитель. 21% воздуха."
|
"descriptionRu": "Необходим для горения и дыхания. Сильный окислитель. 21% воздуха."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"symbol": "F",
|
||||||
|
"name": "Fluorine",
|
||||||
|
"nameRu": "Фтор",
|
||||||
|
"atomicNumber": 9,
|
||||||
|
"atomicMass": 18.998,
|
||||||
|
"electronegativity": 3.98,
|
||||||
|
"category": "halogen",
|
||||||
|
"state": "gas",
|
||||||
|
"color": "#90e050",
|
||||||
|
"description": "Most reactive element in existence. Attacks almost everything, even glass. Handle with extreme caution.",
|
||||||
|
"descriptionRu": "Самый реактивный элемент в природе. Атакует почти всё, даже стекло. Обращаться с предельной осторожностью."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "Ne",
|
||||||
|
"name": "Neon",
|
||||||
|
"nameRu": "Неон",
|
||||||
|
"atomicNumber": 10,
|
||||||
|
"atomicMass": 20.180,
|
||||||
|
"electronegativity": 0,
|
||||||
|
"category": "noble-gas",
|
||||||
|
"state": "gas",
|
||||||
|
"color": "#b3e3f5",
|
||||||
|
"description": "Noble gas. Produces iconic red-orange glow in discharge tubes. Completely inert.",
|
||||||
|
"descriptionRu": "Благородный газ. Даёт культовое красно-оранжевое свечение в газоразрядных трубках. Полностью инертен."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"symbol": "Na",
|
"symbol": "Na",
|
||||||
"name": "Sodium",
|
"name": "Sodium",
|
||||||
@@ -155,6 +207,19 @@
|
|||||||
"description": "Toxic yellow-green gas. Powerful disinfectant. Combines readily with metals to form salts.",
|
"description": "Toxic yellow-green gas. Powerful disinfectant. Combines readily with metals to form salts.",
|
||||||
"descriptionRu": "Ядовитый жёлто-зелёный газ. Мощный дезинфектант. Легко соединяется с металлами, образуя соли."
|
"descriptionRu": "Ядовитый жёлто-зелёный газ. Мощный дезинфектант. Легко соединяется с металлами, образуя соли."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"symbol": "Ar",
|
||||||
|
"name": "Argon",
|
||||||
|
"nameRu": "Аргон",
|
||||||
|
"atomicNumber": 18,
|
||||||
|
"atomicMass": 39.948,
|
||||||
|
"electronegativity": 0,
|
||||||
|
"category": "noble-gas",
|
||||||
|
"state": "gas",
|
||||||
|
"color": "#80d1e3",
|
||||||
|
"description": "Third most abundant gas in atmosphere (0.93%). Used as shielding gas in welding. Completely inert.",
|
||||||
|
"descriptionRu": "Третий по распространённости газ в атмосфере (0,93%). Защитный газ при сварке. Полностью инертен."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"symbol": "K",
|
"symbol": "K",
|
||||||
"name": "Potassium",
|
"name": "Potassium",
|
||||||
@@ -181,6 +246,45 @@
|
|||||||
"description": "Essential for bones and shells. Reacts with water, but less violently than sodium. Component of limestone and cement.",
|
"description": "Essential for bones and shells. Reacts with water, but less violently than sodium. Component of limestone and cement.",
|
||||||
"descriptionRu": "Необходим для костей и раковин. Реагирует с водой, но менее бурно, чем натрий. Компонент известняка и цемента."
|
"descriptionRu": "Необходим для костей и раковин. Реагирует с водой, но менее бурно, чем натрий. Компонент известняка и цемента."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"symbol": "Ti",
|
||||||
|
"name": "Titanium",
|
||||||
|
"nameRu": "Титан",
|
||||||
|
"atomicNumber": 22,
|
||||||
|
"atomicMass": 47.867,
|
||||||
|
"electronegativity": 1.54,
|
||||||
|
"category": "transition-metal",
|
||||||
|
"state": "solid",
|
||||||
|
"color": "#bfc2c7",
|
||||||
|
"description": "Strong as steel but 45% lighter. Corrosion-resistant. Used in aerospace, implants, and armor.",
|
||||||
|
"descriptionRu": "Прочен как сталь, но на 45% легче. Коррозионностойкий. Применяется в авиации, имплантах и броне."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "Cr",
|
||||||
|
"name": "Chromium",
|
||||||
|
"nameRu": "Хром",
|
||||||
|
"atomicNumber": 24,
|
||||||
|
"atomicMass": 51.996,
|
||||||
|
"electronegativity": 1.66,
|
||||||
|
"category": "transition-metal",
|
||||||
|
"state": "solid",
|
||||||
|
"color": "#8a99c7",
|
||||||
|
"description": "Hardest pure metal. Chrome plating resists corrosion. Stainless steel contains 10-20% chromium.",
|
||||||
|
"descriptionRu": "Самый твёрдый чистый металл. Хромирование защищает от коррозии. Нержавеющая сталь содержит 10-20% хрома."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "Mn",
|
||||||
|
"name": "Manganese",
|
||||||
|
"nameRu": "Марганец",
|
||||||
|
"atomicNumber": 25,
|
||||||
|
"atomicMass": 54.938,
|
||||||
|
"electronegativity": 1.55,
|
||||||
|
"category": "transition-metal",
|
||||||
|
"state": "solid",
|
||||||
|
"color": "#9c7ac7",
|
||||||
|
"description": "Essential for steel production. MnO₂ is a natural catalyst and battery material. Biological enzyme cofactor.",
|
||||||
|
"descriptionRu": "Необходим для производства стали. MnO₂ — природный катализатор и материал батарей. Кофактор биологических ферментов."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"symbol": "Fe",
|
"symbol": "Fe",
|
||||||
"name": "Iron",
|
"name": "Iron",
|
||||||
@@ -194,6 +298,32 @@
|
|||||||
"description": "Strong, abundant metal. Rusts in moist air. Core of Earth is mostly iron. Magnetic.",
|
"description": "Strong, abundant metal. Rusts in moist air. Core of Earth is mostly iron. Magnetic.",
|
||||||
"descriptionRu": "Прочный, распространённый металл. Ржавеет на влажном воздухе. Ядро Земли в основном из железа. Магнитен."
|
"descriptionRu": "Прочный, распространённый металл. Ржавеет на влажном воздухе. Ядро Земли в основном из железа. Магнитен."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"symbol": "Co",
|
||||||
|
"name": "Cobalt",
|
||||||
|
"nameRu": "Кобальт",
|
||||||
|
"atomicNumber": 27,
|
||||||
|
"atomicMass": 58.933,
|
||||||
|
"electronegativity": 1.88,
|
||||||
|
"category": "transition-metal",
|
||||||
|
"state": "solid",
|
||||||
|
"color": "#f090a0",
|
||||||
|
"description": "Source of brilliant blue pigment since antiquity. Magnetic. Essential in vitamin B12.",
|
||||||
|
"descriptionRu": "Источник насыщенного синего пигмента с древности. Магнитен. Входит в состав витамина B12."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "Ni",
|
||||||
|
"name": "Nickel",
|
||||||
|
"nameRu": "Никель",
|
||||||
|
"atomicNumber": 28,
|
||||||
|
"atomicMass": 58.693,
|
||||||
|
"electronegativity": 1.91,
|
||||||
|
"category": "transition-metal",
|
||||||
|
"state": "solid",
|
||||||
|
"color": "#50d050",
|
||||||
|
"description": "Corrosion-resistant metal. Key alloy component. Excellent catalyst for hydrogenation reactions.",
|
||||||
|
"descriptionRu": "Коррозионностойкий металл. Важный компонент сплавов. Превосходный катализатор гидрирования."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"symbol": "Cu",
|
"symbol": "Cu",
|
||||||
"name": "Copper",
|
"name": "Copper",
|
||||||
@@ -220,6 +350,45 @@
|
|||||||
"description": "Protects iron from rusting (galvanization). Zinc sulfide glows under UV light. Essential trace nutrient.",
|
"description": "Protects iron from rusting (galvanization). Zinc sulfide glows under UV light. Essential trace nutrient.",
|
||||||
"descriptionRu": "Защищает железо от ржавчины (гальванизация). Сульфид цинка светится в УФ-свете. Необходимый микроэлемент."
|
"descriptionRu": "Защищает железо от ржавчины (гальванизация). Сульфид цинка светится в УФ-свете. Необходимый микроэлемент."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"symbol": "As",
|
||||||
|
"name": "Arsenic",
|
||||||
|
"nameRu": "Мышьяк",
|
||||||
|
"atomicNumber": 33,
|
||||||
|
"atomicMass": 74.922,
|
||||||
|
"electronegativity": 2.18,
|
||||||
|
"category": "metalloid",
|
||||||
|
"state": "solid",
|
||||||
|
"color": "#bd80e3",
|
||||||
|
"description": "Infamous poison. 'Inheritance powder' of the Borgias. Also a semiconductor used in LEDs and lasers.",
|
||||||
|
"descriptionRu": "Печально известный яд. «Порошок наследников» Борджиа. Также полупроводник для светодиодов и лазеров."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "Br",
|
||||||
|
"name": "Bromine",
|
||||||
|
"nameRu": "Бром",
|
||||||
|
"atomicNumber": 35,
|
||||||
|
"atomicMass": 79.904,
|
||||||
|
"electronegativity": 2.96,
|
||||||
|
"category": "halogen",
|
||||||
|
"state": "liquid",
|
||||||
|
"color": "#a62929",
|
||||||
|
"description": "Only non-metallic element that is liquid at room temperature. Dark red, fuming, and corrosive.",
|
||||||
|
"descriptionRu": "Единственный неметалл, жидкий при комнатной температуре. Тёмно-красный, дымящий, едкий."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "Ag",
|
||||||
|
"name": "Silver",
|
||||||
|
"nameRu": "Серебро",
|
||||||
|
"atomicNumber": 47,
|
||||||
|
"atomicMass": 107.868,
|
||||||
|
"electronegativity": 1.93,
|
||||||
|
"category": "transition-metal",
|
||||||
|
"state": "solid",
|
||||||
|
"color": "#c0c0c0",
|
||||||
|
"description": "Best electrical and thermal conductor of all metals. Natural antimicrobial. Tarnishes in sulfur-containing air.",
|
||||||
|
"descriptionRu": "Лучший проводник электричества и тепла среди металлов. Природный антисептик. Темнеет в воздухе с серой."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"symbol": "Sn",
|
"symbol": "Sn",
|
||||||
"name": "Tin",
|
"name": "Tin",
|
||||||
@@ -233,6 +402,58 @@
|
|||||||
"description": "Soft, malleable metal. Resists corrosion. Used for solder and tin plating. Alloy with copper makes bronze.",
|
"description": "Soft, malleable metal. Resists corrosion. Used for solder and tin plating. Alloy with copper makes bronze.",
|
||||||
"descriptionRu": "Мягкий, ковкий металл. Устойчив к коррозии. Используется для пайки и лужения. Сплав с медью — бронза."
|
"descriptionRu": "Мягкий, ковкий металл. Устойчив к коррозии. Используется для пайки и лужения. Сплав с медью — бронза."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"symbol": "I",
|
||||||
|
"name": "Iodine",
|
||||||
|
"nameRu": "Йод",
|
||||||
|
"atomicNumber": 53,
|
||||||
|
"atomicMass": 126.904,
|
||||||
|
"electronegativity": 2.66,
|
||||||
|
"category": "halogen",
|
||||||
|
"state": "solid",
|
||||||
|
"color": "#940094",
|
||||||
|
"description": "Purple-black crystals that sublimate into violet vapor. Essential for thyroid function. Powerful disinfectant.",
|
||||||
|
"descriptionRu": "Пурпурно-чёрные кристаллы, сублимирующие в фиолетовый пар. Необходим для щитовидной железы. Мощный антисептик."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "Ba",
|
||||||
|
"name": "Barium",
|
||||||
|
"nameRu": "Барий",
|
||||||
|
"atomicNumber": 56,
|
||||||
|
"atomicMass": 137.327,
|
||||||
|
"electronegativity": 0.89,
|
||||||
|
"category": "alkaline-earth",
|
||||||
|
"state": "solid",
|
||||||
|
"color": "#00c900",
|
||||||
|
"description": "Alkaline earth metal. Burns with bright green flame (fireworks!). BaSO₄ used in X-ray contrast imaging.",
|
||||||
|
"descriptionRu": "Щёлочноземельный металл. Горит ярко-зелёным пламенем (фейерверки!). BaSO₄ используется при рентгеновской диагностике."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "W",
|
||||||
|
"name": "Tungsten",
|
||||||
|
"nameRu": "Вольфрам",
|
||||||
|
"atomicNumber": 74,
|
||||||
|
"atomicMass": 183.84,
|
||||||
|
"electronegativity": 2.36,
|
||||||
|
"category": "transition-metal",
|
||||||
|
"state": "solid",
|
||||||
|
"color": "#2194d6",
|
||||||
|
"description": "Highest melting point of any element (3422°C). Extremely hard and dense. Light bulb filaments.",
|
||||||
|
"descriptionRu": "Самая высокая температура плавления среди элементов (3422°C). Крайне твёрд и плотен. Нити накаливания ламп."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "Pt",
|
||||||
|
"name": "Platinum",
|
||||||
|
"nameRu": "Платина",
|
||||||
|
"atomicNumber": 78,
|
||||||
|
"atomicMass": 195.084,
|
||||||
|
"electronegativity": 2.28,
|
||||||
|
"category": "transition-metal",
|
||||||
|
"state": "solid",
|
||||||
|
"color": "#d0d0e0",
|
||||||
|
"description": "Noble metal and supreme catalyst. Resists corrosion. Catalytic converters, lab equipment, jewelry.",
|
||||||
|
"descriptionRu": "Благородный металл и превосходный катализатор. Не поддаётся коррозии. Каталитические нейтрализаторы, лаборатории, ювелирные изделия."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"symbol": "Au",
|
"symbol": "Au",
|
||||||
"name": "Gold",
|
"name": "Gold",
|
||||||
@@ -258,5 +479,44 @@
|
|||||||
"color": "#b8b8d0",
|
"color": "#b8b8d0",
|
||||||
"description": "Only metal that is liquid at room temperature. Extremely toxic — damages brain and kidneys. Handle with extreme care.",
|
"description": "Only metal that is liquid at room temperature. Extremely toxic — damages brain and kidneys. Handle with extreme care.",
|
||||||
"descriptionRu": "Единственный металл, жидкий при комнатной температуре. Крайне токсичен — поражает мозг и почки. Обращаться с предельной осторожностью."
|
"descriptionRu": "Единственный металл, жидкий при комнатной температуре. Крайне токсичен — поражает мозг и почки. Обращаться с предельной осторожностью."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "Pb",
|
||||||
|
"name": "Lead",
|
||||||
|
"nameRu": "Свинец",
|
||||||
|
"atomicNumber": 82,
|
||||||
|
"atomicMass": 207.2,
|
||||||
|
"electronegativity": 2.33,
|
||||||
|
"category": "post-transition-metal",
|
||||||
|
"state": "solid",
|
||||||
|
"color": "#575961",
|
||||||
|
"description": "Dense, soft, toxic metal. Shields against radiation. Used in batteries. Cumulative neurotoxin.",
|
||||||
|
"descriptionRu": "Плотный, мягкий, токсичный металл. Экранирует от радиации. Используется в аккумуляторах. Кумулятивный нейротоксин."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "Bi",
|
||||||
|
"name": "Bismuth",
|
||||||
|
"nameRu": "Висмут",
|
||||||
|
"atomicNumber": 83,
|
||||||
|
"atomicMass": 208.980,
|
||||||
|
"electronegativity": 2.02,
|
||||||
|
"category": "post-transition-metal",
|
||||||
|
"state": "solid",
|
||||||
|
"color": "#9e4fb5",
|
||||||
|
"description": "Least toxic heavy metal. Forms beautiful iridescent crystals. Used in Pepto-Bismol and cosmetics.",
|
||||||
|
"descriptionRu": "Наименее токсичный тяжёлый металл. Образует красивые радужные кристаллы. Применяется в медицине и косметике."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "U",
|
||||||
|
"name": "Uranium",
|
||||||
|
"nameRu": "Уран",
|
||||||
|
"atomicNumber": 92,
|
||||||
|
"atomicMass": 238.029,
|
||||||
|
"electronegativity": 1.38,
|
||||||
|
"category": "actinide",
|
||||||
|
"state": "solid",
|
||||||
|
"color": "#008fff",
|
||||||
|
"description": "Radioactive element. Enormous energy density — 1 kg equals 3000 tonnes of coal. Powers nuclear reactors.",
|
||||||
|
"descriptionRu": "Радиоактивный элемент. Колоссальная плотность энергии — 1 кг равен 3000 тоннам угля. Питает ядерные реакторы."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
75
src/data/mycelium.json
Normal file
75
src/data/mycelium.json
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"sporeBonuses": [
|
||||||
|
{
|
||||||
|
"id": "vitality",
|
||||||
|
"name": "Vital Spores",
|
||||||
|
"nameRu": "Споры Живучести",
|
||||||
|
"description": "Mycelium reinforces your body. +25 max health.",
|
||||||
|
"descriptionRu": "Мицелий укрепляет тело. +25 к максимальному здоровью.",
|
||||||
|
"cost": 20,
|
||||||
|
"effect": { "type": "extra_health", "amount": 25 },
|
||||||
|
"repeatable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "carbon_gift",
|
||||||
|
"name": "Carbon Cache",
|
||||||
|
"nameRu": "Запас Углерода",
|
||||||
|
"description": "A fungal node releases stored carbon. +3 C.",
|
||||||
|
"descriptionRu": "Грибной узел отдаёт накопленный углерод. +3 C.",
|
||||||
|
"cost": 10,
|
||||||
|
"effect": { "type": "extra_element", "symbol": "C", "quantity": 3 },
|
||||||
|
"repeatable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sulfur_gift",
|
||||||
|
"name": "Sulfur Reserve",
|
||||||
|
"nameRu": "Запас Серы",
|
||||||
|
"description": "Deep mycelium threads carry sulfur to the surface. +3 S.",
|
||||||
|
"descriptionRu": "Глубинные нити мицелия доставляют серу. +3 S.",
|
||||||
|
"cost": 10,
|
||||||
|
"effect": { "type": "extra_element", "symbol": "S", "quantity": 3 },
|
||||||
|
"repeatable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "iron_gift",
|
||||||
|
"name": "Iron Vein",
|
||||||
|
"nameRu": "Железная Жила",
|
||||||
|
"description": "The network has absorbed iron from old ruins. +2 Fe.",
|
||||||
|
"descriptionRu": "Сеть впитала железо из древних руин. +2 Fe.",
|
||||||
|
"cost": 15,
|
||||||
|
"effect": { "type": "extra_element", "symbol": "Fe", "quantity": 2 },
|
||||||
|
"repeatable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "memory_boost",
|
||||||
|
"name": "Fungal Attunement",
|
||||||
|
"nameRu": "Грибная Настройка",
|
||||||
|
"description": "Clearer memories from the network. Memory flashes are more detailed.",
|
||||||
|
"descriptionRu": "Яснее воспоминания сети. Вспышки памяти более детальны.",
|
||||||
|
"cost": 30,
|
||||||
|
"effect": { "type": "knowledge_boost", "multiplier": 1.5 },
|
||||||
|
"repeatable": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"memoryTemplates": {
|
||||||
|
"element_hint": [
|
||||||
|
{ "text": "A faint echo... {element} reacts with something nearby...", "textRu": "Слабое эхо... {element} реагирует с чем-то рядом..." },
|
||||||
|
{ "text": "The network remembers: {element} was useful here...", "textRu": "Сеть помнит: {element} был полезен здесь..." },
|
||||||
|
{ "text": "Traces of {element} flow through the mycelium threads...", "textRu": "Следы {element} текут по нитям мицелия..." }
|
||||||
|
],
|
||||||
|
"reaction_hint": [
|
||||||
|
{ "text": "A past adept combined... the result was {compound}...", "textRu": "Прошлый адепт комбинировал... результат — {compound}..." },
|
||||||
|
{ "text": "The fungal memory shows: mixing yields {compound}...", "textRu": "Грибная память показывает: смешение даёт {compound}..." },
|
||||||
|
{ "text": "Chemical echoes: a reaction produced {compound} here...", "textRu": "Химическое эхо: реакция создала {compound} здесь..." }
|
||||||
|
],
|
||||||
|
"creature_hint": [
|
||||||
|
{ "text": "Beware: {creature} was observed in this region...", "textRu": "Осторожно: {creature} замечен в этом регионе..." },
|
||||||
|
{ "text": "The network felt vibrations... {creature} territory...", "textRu": "Сеть ощутила вибрации... территория {creature}..." }
|
||||||
|
],
|
||||||
|
"lore": [
|
||||||
|
{ "text": "...the cycle continues. What dies feeds what grows...", "textRu": "...цикл продолжается. Что умирает, питает растущее..." },
|
||||||
|
{ "text": "...synthesis is not creation. It is transformation...", "textRu": "...синтез — не создание. Это трансформация..." },
|
||||||
|
{ "text": "...the Mycelium remembers all who walked these paths...", "textRu": "...Мицелий помнит всех, кто шёл этими тропами..." }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -322,5 +322,822 @@
|
|||||||
"description": "Lime burning: heating limestone drives off CO₂, leaving quicklime. Ancient building technology.",
|
"description": "Lime burning: heating limestone drives off CO₂, leaving quicklime. Ancient building technology.",
|
||||||
"descriptionRu": "Обжиг извести: нагрев известняка выгоняет CO₂, оставляя негашёную известь. Древняя строительная технология.",
|
"descriptionRu": "Обжиг извести: нагрев известняка выгоняет CO₂, оставляя негашёную известь. Древняя строительная технология.",
|
||||||
"difficulty": 3
|
"difficulty": 3
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": "synth_hf", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "H", "count": 1 }, { "id": "F", "count": 1 }],
|
||||||
|
"products": [{ "id": "HF", "count": 1 }],
|
||||||
|
"energyChange": -3,
|
||||||
|
"description": "Hydrogen + fluorine combine to form hydrofluoric acid — dissolves glass!",
|
||||||
|
"descriptionRu": "Водород + фтор образуют плавиковую кислоту — растворяет стекло!",
|
||||||
|
"difficulty": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_hbr", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "H", "count": 1 }, { "id": "Br", "count": 1 }],
|
||||||
|
"products": [{ "id": "HBr", "count": 1 }],
|
||||||
|
"energyChange": -4,
|
||||||
|
"description": "Hydrogen + bromine → hydrobromic acid, a strong acid",
|
||||||
|
"descriptionRu": "Водород + бром → бромоводородная кислота, сильная кислота",
|
||||||
|
"difficulty": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_hi", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "H", "count": 1 }, { "id": "I", "count": 1 }],
|
||||||
|
"products": [{ "id": "HI", "count": 1 }],
|
||||||
|
"energyChange": -3,
|
||||||
|
"description": "Hydrogen + iodine → hydroiodic acid. Strongest hydrohalic acid",
|
||||||
|
"descriptionRu": "Водород + йод → йодоводородная кислота. Самая сильная галогенводородная кислота",
|
||||||
|
"difficulty": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_licl", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "Li", "count": 1 }, { "id": "Cl", "count": 1 }],
|
||||||
|
"products": [{ "id": "LiCl", "count": 1 }],
|
||||||
|
"energyChange": -38,
|
||||||
|
"description": "Lithium + chlorine → lithium chloride. Hygroscopic desiccant",
|
||||||
|
"descriptionRu": "Литий + хлор → хлорид лития. Гигроскопичный осушитель",
|
||||||
|
"difficulty": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_lioh", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "Li", "count": 1 }, { "id": "O", "count": 1 }, { "id": "H", "count": 1 }],
|
||||||
|
"products": [{ "id": "LiOH", "count": 1 }],
|
||||||
|
"energyChange": -45,
|
||||||
|
"description": "Lithium hydroxide synthesis. Used in spacecraft CO₂ scrubbers",
|
||||||
|
"descriptionRu": "Синтез гидроксида лития. Используется для очистки воздуха на космических кораблях",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_agcl", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "Ag", "count": 1 }, { "id": "Cl", "count": 1 }],
|
||||||
|
"products": [{ "id": "AgCl", "count": 1 }],
|
||||||
|
"energyChange": -13,
|
||||||
|
"description": "Silver + chlorine → silver chloride. Darkens in light — basis of photography",
|
||||||
|
"descriptionRu": "Серебро + хлор → хлорид серебра. Темнеет на свету — основа фотографии",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_agbr", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "Ag", "count": 1 }, { "id": "Br", "count": 1 }],
|
||||||
|
"products": [{ "id": "AgBr", "count": 1 }],
|
||||||
|
"energyChange": -10,
|
||||||
|
"description": "Silver + bromine → silver bromide. More light-sensitive than AgCl",
|
||||||
|
"descriptionRu": "Серебро + бром → бромид серебра. Светочувствительнее AgCl",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_pbs", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "Pb", "count": 1 }, { "id": "S", "count": 1 }],
|
||||||
|
"products": [{ "id": "PbS", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 500 },
|
||||||
|
"energyChange": -10,
|
||||||
|
"description": "Lead + sulfur → galena (lead sulfide). First semiconductor",
|
||||||
|
"descriptionRu": "Свинец + сера → галенит (сульфид свинца). Первый полупроводник",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_nicl2", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "Ni", "count": 1 }, { "id": "Cl", "count": 2 }],
|
||||||
|
"products": [{ "id": "NiCl2", "count": 1 }],
|
||||||
|
"energyChange": -31,
|
||||||
|
"description": "Nickel + chlorine → green nickel chloride. Electroplating reagent",
|
||||||
|
"descriptionRu": "Никель + хлор → зелёный хлорид никеля. Реагент для гальваники",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_naf", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "Na", "count": 1 }, { "id": "F", "count": 1 }],
|
||||||
|
"products": [{ "id": "NaF", "count": 1 }],
|
||||||
|
"energyChange": -57,
|
||||||
|
"description": "Sodium + fluorine → sodium fluoride. Active ingredient in toothpaste",
|
||||||
|
"descriptionRu": "Натрий + фтор → фторид натрия. Активный компонент зубной пасты",
|
||||||
|
"difficulty": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_nabr", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "Na", "count": 1 }, { "id": "Br", "count": 1 }],
|
||||||
|
"products": [{ "id": "NaBr", "count": 1 }],
|
||||||
|
"energyChange": -36,
|
||||||
|
"description": "Sodium + bromine → sodium bromide. Historical sedative",
|
||||||
|
"descriptionRu": "Натрий + бром → бромид натрия. Историческое седативное средство",
|
||||||
|
"difficulty": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_kf", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "K", "count": 1 }, { "id": "F", "count": 1 }],
|
||||||
|
"products": [{ "id": "KF", "count": 1 }],
|
||||||
|
"energyChange": -56,
|
||||||
|
"description": "Potassium + fluorine → potassium fluoride",
|
||||||
|
"descriptionRu": "Калий + фтор → фторид калия",
|
||||||
|
"difficulty": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_kbr", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "K", "count": 1 }, { "id": "Br", "count": 1 }],
|
||||||
|
"products": [{ "id": "KBr", "count": 1 }],
|
||||||
|
"energyChange": -39,
|
||||||
|
"description": "Potassium + bromine → potassium bromide. Photography essential",
|
||||||
|
"descriptionRu": "Калий + бром → бромид калия. Незаменим в фотографии",
|
||||||
|
"difficulty": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_mgcl2", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "Mg", "count": 1 }, { "id": "Cl", "count": 2 }],
|
||||||
|
"products": [{ "id": "MgCl2", "count": 1 }],
|
||||||
|
"energyChange": -64,
|
||||||
|
"description": "Magnesium + chlorine → magnesium chloride. De-icing salt",
|
||||||
|
"descriptionRu": "Магний + хлор → хлорид магния. Противогололёдная соль",
|
||||||
|
"difficulty": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_cacl2", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "Ca", "count": 1 }, { "id": "Cl", "count": 2 }],
|
||||||
|
"products": [{ "id": "CaCl2", "count": 1 }],
|
||||||
|
"energyChange": -80,
|
||||||
|
"description": "Calcium + chlorine → calcium chloride. Powerful desiccant",
|
||||||
|
"descriptionRu": "Кальций + хлор → хлорид кальция. Мощный осушитель",
|
||||||
|
"difficulty": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_bacl2", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "Ba", "count": 1 }, { "id": "Cl", "count": 2 }],
|
||||||
|
"products": [{ "id": "BaCl2", "count": 1 }],
|
||||||
|
"energyChange": -86,
|
||||||
|
"description": "Barium + chlorine → barium chloride. Green flame reagent",
|
||||||
|
"descriptionRu": "Барий + хлор → хлорид бария. Реагент зелёного пламени",
|
||||||
|
"difficulty": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_fecl3", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "Fe", "count": 1 }, { "id": "Cl", "count": 3 }],
|
||||||
|
"products": [{ "id": "FeCl3", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 500 },
|
||||||
|
"energyChange": -40,
|
||||||
|
"description": "Iron + excess chlorine → iron(III) chloride. Etches copper circuit boards",
|
||||||
|
"descriptionRu": "Железо + избыток хлора → хлорид железа(III). Травит медные платы",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_fecl2", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "Fe", "count": 1 }, { "id": "Cl", "count": 2 }],
|
||||||
|
"products": [{ "id": "FeCl2", "count": 1 }],
|
||||||
|
"energyChange": -34,
|
||||||
|
"description": "Iron + chlorine → iron(II) chloride. Reducing agent",
|
||||||
|
"descriptionRu": "Железо + хлор → хлорид железа(II). Восстановитель",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_cucl2", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "Cu", "count": 1 }, { "id": "Cl", "count": 2 }],
|
||||||
|
"products": [{ "id": "CuCl2", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 500 },
|
||||||
|
"energyChange": -22,
|
||||||
|
"description": "Copper + chlorine at heat → green copper(II) chloride crystals",
|
||||||
|
"descriptionRu": "Медь + хлор при нагреве → зелёные кристаллы хлорида меди(II)",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_zncl2", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "Zn", "count": 1 }, { "id": "Cl", "count": 2 }],
|
||||||
|
"products": [{ "id": "ZnCl2", "count": 1 }],
|
||||||
|
"energyChange": -42,
|
||||||
|
"description": "Zinc + chlorine → zinc chloride. Soldering flux",
|
||||||
|
"descriptionRu": "Цинк + хлор → хлорид цинка. Паяльный флюс",
|
||||||
|
"difficulty": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_na2o", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "Na", "count": 2 }, { "id": "O", "count": 1 }],
|
||||||
|
"products": [{ "id": "Na2O", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 500 },
|
||||||
|
"energyChange": -42,
|
||||||
|
"description": "Sodium burns in limited oxygen → sodium oxide. Key to glass making",
|
||||||
|
"descriptionRu": "Натрий горит в ограниченном кислороде → оксид натрия. Ключ к стекловарению",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_nh3", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "N", "count": 1 }, { "id": "H", "count": 3 }],
|
||||||
|
"products": [{ "id": "NH3", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 500, "catalyst": "Fe" },
|
||||||
|
"energyChange": -5,
|
||||||
|
"description": "Haber process: nitrogen + hydrogen with iron catalyst → ammonia. Nobel Prize reaction",
|
||||||
|
"descriptionRu": "Процесс Габера: азот + водород с железным катализатором → аммиак. Нобелевская реакция",
|
||||||
|
"difficulty": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_h2s", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "H", "count": 2 }, { "id": "S", "count": 1 }],
|
||||||
|
"products": [{ "id": "H2S", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 500 },
|
||||||
|
"energyChange": -2,
|
||||||
|
"description": "Hydrogen + sulfur → hydrogen sulfide. Rotten egg gas",
|
||||||
|
"descriptionRu": "Водород + сера → сероводород. Газ с запахом тухлых яиц",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_h2so4", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "S", "count": 1 }, { "id": "O", "count": 3 }, { "id": "H", "count": 2 }],
|
||||||
|
"products": [{ "id": "H2SO4", "count": 1 }],
|
||||||
|
"conditions": { "catalyst": "Pt" },
|
||||||
|
"energyChange": -14,
|
||||||
|
"description": "Contact process: sulfur, oxygen, hydrogen with platinum catalyst → sulfuric acid",
|
||||||
|
"descriptionRu": "Контактный процесс: сера, кислород, водород с платиновым катализатором → серная кислота",
|
||||||
|
"difficulty": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_hno3", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "N", "count": 1 }, { "id": "O", "count": 3 }, { "id": "H", "count": 1 }],
|
||||||
|
"products": [{ "id": "HNO3", "count": 1 }],
|
||||||
|
"conditions": { "catalyst": "Pt" },
|
||||||
|
"energyChange": -5,
|
||||||
|
"description": "Ostwald process: nitrogen, oxygen, hydrogen with platinum catalyst → nitric acid",
|
||||||
|
"descriptionRu": "Процесс Оствальда: азот, кислород, водород с платиновым катализатором → азотная кислота",
|
||||||
|
"difficulty": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_nh4cl", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "NH3", "count": 1 }, { "id": "HCl", "count": 1 }],
|
||||||
|
"products": [{ "id": "NH4Cl", "count": 1 }],
|
||||||
|
"energyChange": -18,
|
||||||
|
"description": "Ammonia + HCl → white smoke of ammonium chloride! Classic demonstration",
|
||||||
|
"descriptionRu": "Аммиак + HCl → белый дым хлорида аммония! Классическая демонстрация",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_ch3cooh", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "C", "count": 2 }, { "id": "H", "count": 4 }, { "id": "O", "count": 2 }],
|
||||||
|
"products": [{ "id": "CH3COOH", "count": 1 }],
|
||||||
|
"conditions": { "catalyst": "Cu" },
|
||||||
|
"energyChange": -5,
|
||||||
|
"description": "Acetic acid synthesis with copper catalyst. The chemistry of vinegar",
|
||||||
|
"descriptionRu": "Синтез уксусной кислоты с медным катализатором. Химия уксуса",
|
||||||
|
"difficulty": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_c6h12o6", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "C", "count": 6 }, { "id": "H", "count": 12 }, { "id": "O", "count": 6 }],
|
||||||
|
"products": [{ "id": "C6H12O6", "count": 1 }],
|
||||||
|
"conditions": { "requiresEnergy": true },
|
||||||
|
"energyChange": 28,
|
||||||
|
"description": "Artificial photosynthesis: assembling glucose from its elements. Requires enormous energy",
|
||||||
|
"descriptionRu": "Искусственный фотосинтез: сборка глюкозы из элементов. Требует огромной энергии",
|
||||||
|
"difficulty": 5
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": "comb_tio2", "type": "combustion",
|
||||||
|
"reactants": [{ "id": "Ti", "count": 1 }, { "id": "O", "count": 2 }],
|
||||||
|
"products": [{ "id": "TiO2", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 1000 },
|
||||||
|
"energyChange": -94,
|
||||||
|
"description": "Titanium burns brilliantly in pure oxygen. Produces white titanium dioxide",
|
||||||
|
"descriptionRu": "Титан ярко горит в чистом кислороде. Образует белый диоксид титана",
|
||||||
|
"difficulty": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "comb_cr2o3", "type": "combustion",
|
||||||
|
"reactants": [{ "id": "Cr", "count": 2 }, { "id": "O", "count": 3 }],
|
||||||
|
"products": [{ "id": "Cr2O3", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 1000 },
|
||||||
|
"energyChange": -113,
|
||||||
|
"description": "Chromium oxidation at extreme heat → green chromium oxide. Hard as corundum",
|
||||||
|
"descriptionRu": "Окисление хрома при экстремальном нагреве → зелёный оксид хрома. Твёрд как корунд",
|
||||||
|
"difficulty": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "comb_mno2", "type": "combustion",
|
||||||
|
"reactants": [{ "id": "Mn", "count": 1 }, { "id": "O", "count": 2 }],
|
||||||
|
"products": [{ "id": "MnO2", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 500 },
|
||||||
|
"energyChange": -52,
|
||||||
|
"description": "Manganese + oxygen → manganese dioxide. Nature's battery material and catalyst",
|
||||||
|
"descriptionRu": "Марганец + кислород → диоксид марганца. Природный материал батарей и катализатор",
|
||||||
|
"difficulty": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "comb_coo", "type": "combustion",
|
||||||
|
"reactants": [{ "id": "Co", "count": 1 }, { "id": "O", "count": 1 }],
|
||||||
|
"products": [{ "id": "CoO", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 500 },
|
||||||
|
"energyChange": -24,
|
||||||
|
"description": "Cobalt burns to form cobalt oxide — brilliant blue ceramic pigment",
|
||||||
|
"descriptionRu": "Кобальт горит, образуя оксид кобальта — великолепный синий керамический пигмент",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "comb_cuo", "type": "combustion",
|
||||||
|
"reactants": [{ "id": "Cu", "count": 1 }, { "id": "O", "count": 1 }],
|
||||||
|
"products": [{ "id": "CuO", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 500 },
|
||||||
|
"energyChange": -16,
|
||||||
|
"description": "Copper oxidation → black copper(II) oxide. Basis of ancient smelting",
|
||||||
|
"descriptionRu": "Окисление меди → чёрный оксид меди(II). Основа древней металлургии",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "comb_bao", "type": "combustion",
|
||||||
|
"reactants": [{ "id": "Ba", "count": 1 }, { "id": "O", "count": 1 }],
|
||||||
|
"products": [{ "id": "BaO", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 500 },
|
||||||
|
"energyChange": -56,
|
||||||
|
"description": "Barium burns with brilliant GREEN flame → barium oxide",
|
||||||
|
"descriptionRu": "Барий горит ярко-ЗЕЛЁНЫМ пламенем → оксид бария",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "comb_wo3", "type": "combustion",
|
||||||
|
"reactants": [{ "id": "W", "count": 1 }, { "id": "O", "count": 3 }],
|
||||||
|
"products": [{ "id": "WO3", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 1000 },
|
||||||
|
"energyChange": -84,
|
||||||
|
"description": "Tungsten slowly oxidizes only at extreme temperatures. Highest melting point element",
|
||||||
|
"descriptionRu": "Вольфрам окисляется лишь при экстремальных температурах. Элемент с наивысшей температурой плавления",
|
||||||
|
"difficulty": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "comb_pbo", "type": "combustion",
|
||||||
|
"reactants": [{ "id": "Pb", "count": 1 }, { "id": "O", "count": 1 }],
|
||||||
|
"products": [{ "id": "PbO", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 500 },
|
||||||
|
"energyChange": -22,
|
||||||
|
"description": "Lead oxidation → litharge (yellow lead oxide). Battery material",
|
||||||
|
"descriptionRu": "Окисление свинца → глёт (жёлтый оксид свинца). Материал аккумуляторов",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "comb_bi2o3", "type": "combustion",
|
||||||
|
"reactants": [{ "id": "Bi", "count": 2 }, { "id": "O", "count": 3 }],
|
||||||
|
"products": [{ "id": "Bi2O3", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 500 },
|
||||||
|
"energyChange": -58,
|
||||||
|
"description": "Bismuth burns → bismuth trioxide. Medicinal compound",
|
||||||
|
"descriptionRu": "Висмут горит → оксид висмута(III). Лекарственное соединение",
|
||||||
|
"difficulty": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "comb_uo2", "type": "combustion",
|
||||||
|
"reactants": [{ "id": "U", "count": 1 }, { "id": "O", "count": 2 }],
|
||||||
|
"products": [{ "id": "UO2", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 1000 },
|
||||||
|
"energyChange": -109,
|
||||||
|
"description": "Uranium oxidation → uranium dioxide. Nuclear fuel pellets",
|
||||||
|
"descriptionRu": "Окисление урана → диоксид урана. Таблетки ядерного топлива",
|
||||||
|
"difficulty": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "comb_as2o3", "type": "combustion",
|
||||||
|
"reactants": [{ "id": "As", "count": 2 }, { "id": "O", "count": 3 }],
|
||||||
|
"products": [{ "id": "As2O3", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 500 },
|
||||||
|
"energyChange": -66,
|
||||||
|
"description": "Arsenic combustion → arsenic trioxide. The 'King of Poisons'",
|
||||||
|
"descriptionRu": "Горение мышьяка → оксид мышьяка(III). «Король ядов»",
|
||||||
|
"difficulty": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "comb_ch4", "type": "combustion",
|
||||||
|
"reactants": [{ "id": "CH4", "count": 1 }, { "id": "O", "count": 4 }],
|
||||||
|
"products": [{ "id": "CO2", "count": 1 }, { "id": "H2O", "count": 2 }],
|
||||||
|
"conditions": { "minTemp": 500 },
|
||||||
|
"energyChange": -89,
|
||||||
|
"description": "Methane combustion: natural gas burning. Clean energy source",
|
||||||
|
"descriptionRu": "Горение метана: сжигание природного газа. Чистый источник энергии",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "comb_c2h5oh", "type": "combustion",
|
||||||
|
"reactants": [{ "id": "C2H5OH", "count": 1 }, { "id": "O", "count": 6 }],
|
||||||
|
"products": [{ "id": "CO2", "count": 2 }, { "id": "H2O", "count": 3 }],
|
||||||
|
"conditions": { "minTemp": 500 },
|
||||||
|
"energyChange": -137,
|
||||||
|
"description": "Ethanol burns with blue flame. Clean-burning biofuel",
|
||||||
|
"descriptionRu": "Этанол горит синим пламенем. Чистое биотопливо",
|
||||||
|
"difficulty": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "comb_h2s", "type": "combustion",
|
||||||
|
"reactants": [{ "id": "H2S", "count": 1 }, { "id": "O", "count": 3 }],
|
||||||
|
"products": [{ "id": "SO2", "count": 1 }, { "id": "H2O", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 500 },
|
||||||
|
"energyChange": -52,
|
||||||
|
"description": "Hydrogen sulfide burns → sulfur dioxide + water. Converting one toxic gas to another",
|
||||||
|
"descriptionRu": "Сероводород горит → сернистый газ + вода. Превращение одного токсичного газа в другой",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "comb_co_full", "type": "combustion",
|
||||||
|
"reactants": [{ "id": "CO", "count": 1 }, { "id": "O", "count": 1 }],
|
||||||
|
"products": [{ "id": "CO2", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 500 },
|
||||||
|
"energyChange": -28,
|
||||||
|
"description": "Carbon monoxide burns to carbon dioxide. Converting deadly poison to harmless gas",
|
||||||
|
"descriptionRu": "Угарный газ горит до углекислого. Превращение смертельного яда в безвредный газ",
|
||||||
|
"difficulty": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "comb_glucose", "type": "combustion",
|
||||||
|
"reactants": [{ "id": "C6H12O6", "count": 1 }, { "id": "O", "count": 12 }],
|
||||||
|
"products": [{ "id": "CO2", "count": 6 }, { "id": "H2O", "count": 6 }],
|
||||||
|
"conditions": { "minTemp": 500 },
|
||||||
|
"energyChange": -280,
|
||||||
|
"description": "Glucose combustion — same as cellular respiration. Maximum energy release from sugar",
|
||||||
|
"descriptionRu": "Горение глюкозы — то же, что клеточное дыхание. Максимальное выделение энергии из сахара",
|
||||||
|
"difficulty": 3
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": "repl_li_h2o", "type": "single-replacement",
|
||||||
|
"reactants": [{ "id": "Li", "count": 1 }, { "id": "H2O", "count": 1 }],
|
||||||
|
"products": [{ "id": "LiOH", "count": 1 }, { "id": "H", "count": 1 }],
|
||||||
|
"energyChange": -50,
|
||||||
|
"description": "Lithium fizzes gently in water → lithium hydroxide + hydrogen. Less violent than sodium",
|
||||||
|
"descriptionRu": "Литий тихо шипит в воде → гидроксид лития + водород. Менее бурно, чем натрий",
|
||||||
|
"difficulty": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "repl_mg_hcl", "type": "single-replacement",
|
||||||
|
"reactants": [{ "id": "Mg", "count": 1 }, { "id": "HCl", "count": 2 }],
|
||||||
|
"products": [{ "id": "MgCl2", "count": 1 }, { "id": "H", "count": 2 }],
|
||||||
|
"energyChange": -46,
|
||||||
|
"description": "Magnesium dissolves in acid with vigorous hydrogen bubbling",
|
||||||
|
"descriptionRu": "Магний растворяется в кислоте с бурным выделением пузырьков водорода",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "repl_zn_hcl", "type": "single-replacement",
|
||||||
|
"reactants": [{ "id": "Zn", "count": 1 }, { "id": "HCl", "count": 2 }],
|
||||||
|
"products": [{ "id": "ZnCl2", "count": 1 }, { "id": "H", "count": 2 }],
|
||||||
|
"energyChange": -15,
|
||||||
|
"description": "Zinc dissolves in hydrochloric acid → zinc chloride + hydrogen gas",
|
||||||
|
"descriptionRu": "Цинк растворяется в соляной кислоте → хлорид цинка + водород",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "repl_fe_hcl", "type": "single-replacement",
|
||||||
|
"reactants": [{ "id": "Fe", "count": 1 }, { "id": "HCl", "count": 2 }],
|
||||||
|
"products": [{ "id": "FeCl2", "count": 1 }, { "id": "H", "count": 2 }],
|
||||||
|
"energyChange": -9,
|
||||||
|
"description": "Iron dissolves in acid → pale green iron(II) chloride + hydrogen",
|
||||||
|
"descriptionRu": "Железо растворяется в кислоте → бледно-зелёный хлорид железа(II) + водород",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "repl_ni_hcl", "type": "single-replacement",
|
||||||
|
"reactants": [{ "id": "Ni", "count": 1 }, { "id": "HCl", "count": 2 }],
|
||||||
|
"products": [{ "id": "NiCl2", "count": 1 }, { "id": "H", "count": 2 }],
|
||||||
|
"energyChange": -8,
|
||||||
|
"description": "Nickel dissolves slowly in HCl → green nickel chloride solution",
|
||||||
|
"descriptionRu": "Никель медленно растворяется в HCl → зелёный раствор хлорида никеля",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "repl_fe_cucl2", "type": "single-replacement",
|
||||||
|
"reactants": [{ "id": "Fe", "count": 1 }, { "id": "CuCl2", "count": 1 }],
|
||||||
|
"products": [{ "id": "FeCl2", "count": 1 }, { "id": "Cu", "count": 1 }],
|
||||||
|
"energyChange": -15,
|
||||||
|
"description": "Iron displaces copper from solution. Activity series: Fe > Cu",
|
||||||
|
"descriptionRu": "Железо вытесняет медь из раствора. Ряд активности: Fe > Cu",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "repl_zn_cucl2", "type": "single-replacement",
|
||||||
|
"reactants": [{ "id": "Zn", "count": 1 }, { "id": "CuCl2", "count": 1 }],
|
||||||
|
"products": [{ "id": "ZnCl2", "count": 1 }, { "id": "Cu", "count": 1 }],
|
||||||
|
"energyChange": -22,
|
||||||
|
"description": "Zinc displaces copper — red copper deposits on zinc surface",
|
||||||
|
"descriptionRu": "Цинк вытесняет медь — красная медь осаждается на поверхности цинка",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "repl_zn_fecl2", "type": "single-replacement",
|
||||||
|
"reactants": [{ "id": "Zn", "count": 1 }, { "id": "FeCl2", "count": 1 }],
|
||||||
|
"products": [{ "id": "ZnCl2", "count": 1 }, { "id": "Fe", "count": 1 }],
|
||||||
|
"energyChange": -7,
|
||||||
|
"description": "Zinc displaces iron from solution. Zinc is more reactive",
|
||||||
|
"descriptionRu": "Цинк вытесняет железо из раствора. Цинк активнее",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "repl_mg_cucl2", "type": "single-replacement",
|
||||||
|
"reactants": [{ "id": "Mg", "count": 1 }, { "id": "CuCl2", "count": 1 }],
|
||||||
|
"products": [{ "id": "MgCl2", "count": 1 }, { "id": "Cu", "count": 1 }],
|
||||||
|
"energyChange": -60,
|
||||||
|
"description": "Magnesium violently displaces copper. Extreme activity difference",
|
||||||
|
"descriptionRu": "Магний бурно вытесняет медь. Огромная разница активности",
|
||||||
|
"difficulty": 1
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": "ab_lioh_hcl", "type": "acid-base",
|
||||||
|
"reactants": [{ "id": "LiOH", "count": 1 }, { "id": "HCl", "count": 1 }],
|
||||||
|
"products": [{ "id": "LiCl", "count": 1 }, { "id": "H2O", "count": 1 }],
|
||||||
|
"energyChange": -55,
|
||||||
|
"description": "Neutralization: lithium hydroxide + hydrochloric acid → lithium chloride + water",
|
||||||
|
"descriptionRu": "Нейтрализация: гидроксид лития + соляная кислота → хлорид лития + вода",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ab_naoh_hf", "type": "acid-base",
|
||||||
|
"reactants": [{ "id": "NaOH", "count": 1 }, { "id": "HF", "count": 1 }],
|
||||||
|
"products": [{ "id": "NaF", "count": 1 }, { "id": "H2O", "count": 1 }],
|
||||||
|
"energyChange": -68,
|
||||||
|
"description": "Neutralizing deadly HF with lye → safe sodium fluoride + water",
|
||||||
|
"descriptionRu": "Нейтрализация опасного HF щёлочью → безопасный фторид натрия + вода",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ab_naoh_hbr", "type": "acid-base",
|
||||||
|
"reactants": [{ "id": "NaOH", "count": 1 }, { "id": "HBr", "count": 1 }],
|
||||||
|
"products": [{ "id": "NaBr", "count": 1 }, { "id": "H2O", "count": 1 }],
|
||||||
|
"energyChange": -56,
|
||||||
|
"description": "Neutralization: NaOH + HBr → sodium bromide + water",
|
||||||
|
"descriptionRu": "Нейтрализация: NaOH + HBr → бромид натрия + вода",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ab_koh_hf", "type": "acid-base",
|
||||||
|
"reactants": [{ "id": "KOH", "count": 1 }, { "id": "HF", "count": 1 }],
|
||||||
|
"products": [{ "id": "KF", "count": 1 }, { "id": "H2O", "count": 1 }],
|
||||||
|
"energyChange": -68,
|
||||||
|
"description": "Potassium hydroxide neutralizes hydrofluoric acid",
|
||||||
|
"descriptionRu": "Гидроксид калия нейтрализует плавиковую кислоту",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ab_koh_hbr", "type": "acid-base",
|
||||||
|
"reactants": [{ "id": "KOH", "count": 1 }, { "id": "HBr", "count": 1 }],
|
||||||
|
"products": [{ "id": "KBr", "count": 1 }, { "id": "H2O", "count": 1 }],
|
||||||
|
"energyChange": -56,
|
||||||
|
"description": "KOH + HBr → potassium bromide + water",
|
||||||
|
"descriptionRu": "KOH + HBr → бромид калия + вода",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ab_caco3_hcl", "type": "acid-base",
|
||||||
|
"reactants": [{ "id": "CaCO3", "count": 1 }, { "id": "HCl", "count": 2 }],
|
||||||
|
"products": [{ "id": "CaCl2", "count": 1 }, { "id": "H2O", "count": 1 }, { "id": "CO2", "count": 1 }],
|
||||||
|
"energyChange": -12,
|
||||||
|
"description": "Limestone + acid → fizzing! Calcium chloride + water + CO₂. The cave dissolving reaction",
|
||||||
|
"descriptionRu": "Известняк + кислота → шипение! Хлорид кальция + вода + CO₂. Реакция растворения пещер",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ab_cuo_hcl", "type": "acid-base",
|
||||||
|
"reactants": [{ "id": "CuO", "count": 1 }, { "id": "HCl", "count": 2 }],
|
||||||
|
"products": [{ "id": "CuCl2", "count": 1 }, { "id": "H2O", "count": 1 }],
|
||||||
|
"energyChange": -13,
|
||||||
|
"description": "Copper oxide + acid → blue-green copper chloride solution + water",
|
||||||
|
"descriptionRu": "Оксид меди + кислота → сине-зелёный раствор хлорида меди + вода",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ab_zno_hcl", "type": "acid-base",
|
||||||
|
"reactants": [{ "id": "ZnO", "count": 1 }, { "id": "HCl", "count": 2 }],
|
||||||
|
"products": [{ "id": "ZnCl2", "count": 1 }, { "id": "H2O", "count": 1 }],
|
||||||
|
"energyChange": -14,
|
||||||
|
"description": "Zinc oxide + acid → zinc chloride + water. Classic oxide-acid reaction",
|
||||||
|
"descriptionRu": "Оксид цинка + кислота → хлорид цинка + вода. Классическая реакция оксида с кислотой",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ab_mgo_hcl", "type": "acid-base",
|
||||||
|
"reactants": [{ "id": "MgO", "count": 1 }, { "id": "HCl", "count": 2 }],
|
||||||
|
"products": [{ "id": "MgCl2", "count": 1 }, { "id": "H2O", "count": 1 }],
|
||||||
|
"energyChange": -15,
|
||||||
|
"description": "Antacid reaction: magnesium oxide neutralizes stomach acid",
|
||||||
|
"descriptionRu": "Антацидная реакция: оксид магния нейтрализует желудочную кислоту",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ab_bao_hcl", "type": "acid-base",
|
||||||
|
"reactants": [{ "id": "BaO", "count": 1 }, { "id": "HCl", "count": 2 }],
|
||||||
|
"products": [{ "id": "BaCl2", "count": 1 }, { "id": "H2O", "count": 1 }],
|
||||||
|
"energyChange": -17,
|
||||||
|
"description": "Barium oxide + acid → toxic barium chloride. Handle carefully",
|
||||||
|
"descriptionRu": "Оксид бария + кислота → токсичный хлорид бария. Осторожно",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ab_caoh2_hcl", "type": "acid-base",
|
||||||
|
"reactants": [{ "id": "CaOH2", "count": 1 }, { "id": "HCl", "count": 2 }],
|
||||||
|
"products": [{ "id": "CaCl2", "count": 1 }, { "id": "H2O", "count": 2 }],
|
||||||
|
"energyChange": -58,
|
||||||
|
"description": "Slaked lime + acid → calcium chloride + water. Water treatment",
|
||||||
|
"descriptionRu": "Гашёная известь + кислота → хлорид кальция + вода. Очистка воды",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ab_na2o_hcl", "type": "acid-base",
|
||||||
|
"reactants": [{ "id": "Na2O", "count": 1 }, { "id": "HCl", "count": 2 }],
|
||||||
|
"products": [{ "id": "NaCl", "count": 2 }, { "id": "H2O", "count": 1 }],
|
||||||
|
"energyChange": -60,
|
||||||
|
"description": "Sodium oxide + acid → table salt + water",
|
||||||
|
"descriptionRu": "Оксид натрия + кислота → поваренная соль + вода",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "repl_na2o_h2o", "type": "single-replacement",
|
||||||
|
"reactants": [{ "id": "Na2O", "count": 1 }, { "id": "H2O", "count": 1 }],
|
||||||
|
"products": [{ "id": "NaOH", "count": 2 }],
|
||||||
|
"energyChange": -70,
|
||||||
|
"description": "Sodium oxide dissolves in water → caustic soda (NaOH). Exothermic!",
|
||||||
|
"descriptionRu": "Оксид натрия растворяется в воде → каустическая сода (NaOH). Экзотермическая!",
|
||||||
|
"difficulty": 1
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": "decomp_nh3", "type": "decomposition",
|
||||||
|
"reactants": [{ "id": "NH3", "count": 1 }],
|
||||||
|
"products": [{ "id": "N", "count": 1 }, { "id": "H", "count": 3 }],
|
||||||
|
"conditions": { "minTemp": 1000 },
|
||||||
|
"energyChange": 5,
|
||||||
|
"description": "Ammonia decomposes at extreme heat → nitrogen + hydrogen. Reversed Haber process",
|
||||||
|
"descriptionRu": "Аммиак разлагается при экстремальном нагреве → азот + водород. Обратный процесс Габера",
|
||||||
|
"difficulty": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "decomp_h2s", "type": "decomposition",
|
||||||
|
"reactants": [{ "id": "H2S", "count": 1 }],
|
||||||
|
"products": [{ "id": "H", "count": 2 }, { "id": "S", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 1000 },
|
||||||
|
"energyChange": 2,
|
||||||
|
"description": "Thermal decomposition of hydrogen sulfide → hydrogen + sulfur",
|
||||||
|
"descriptionRu": "Термическое разложение сероводорода → водород + сера",
|
||||||
|
"difficulty": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "decomp_hno3", "type": "decomposition",
|
||||||
|
"reactants": [{ "id": "HNO3", "count": 1 }],
|
||||||
|
"products": [{ "id": "N", "count": 1 }, { "id": "O", "count": 3 }, { "id": "H", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 500 },
|
||||||
|
"energyChange": 5,
|
||||||
|
"description": "Nitric acid decomposes under heat → brown toxic fumes",
|
||||||
|
"descriptionRu": "Азотная кислота разлагается при нагреве → бурые токсичные пары",
|
||||||
|
"difficulty": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "decomp_h2so4", "type": "decomposition",
|
||||||
|
"reactants": [{ "id": "H2SO4", "count": 1 }],
|
||||||
|
"products": [{ "id": "S", "count": 1 }, { "id": "O", "count": 3 }, { "id": "H", "count": 2 }],
|
||||||
|
"conditions": { "minTemp": 1000 },
|
||||||
|
"energyChange": 10,
|
||||||
|
"description": "Sulfuric acid decomposition at extreme heat. Very endothermic",
|
||||||
|
"descriptionRu": "Разложение серной кислоты при экстремальном нагреве. Сильно эндотермическое",
|
||||||
|
"difficulty": 4
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": "decomp_nacl_electro", "type": "decomposition",
|
||||||
|
"reactants": [{ "id": "NaCl", "count": 1 }],
|
||||||
|
"products": [{ "id": "Na", "count": 1 }, { "id": "Cl", "count": 1 }],
|
||||||
|
"conditions": { "requiresEnergy": true },
|
||||||
|
"energyChange": 41,
|
||||||
|
"description": "Electrolysis of molten salt → pure sodium + chlorine gas. Industrial sodium production",
|
||||||
|
"descriptionRu": "Электролиз расплава соли → чистый натрий + хлор. Промышленное получение натрия",
|
||||||
|
"difficulty": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "decomp_mgcl2", "type": "decomposition",
|
||||||
|
"reactants": [{ "id": "MgCl2", "count": 1 }],
|
||||||
|
"products": [{ "id": "Mg", "count": 1 }, { "id": "Cl", "count": 2 }],
|
||||||
|
"conditions": { "requiresEnergy": true },
|
||||||
|
"energyChange": 64,
|
||||||
|
"description": "Electrolysis of molten MgCl₂ → magnesium metal + chlorine. Dow process",
|
||||||
|
"descriptionRu": "Электролиз расплава MgCl₂ → металлический магний + хлор. Процесс Дау",
|
||||||
|
"difficulty": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "decomp_cacl2", "type": "decomposition",
|
||||||
|
"reactants": [{ "id": "CaCl2", "count": 1 }],
|
||||||
|
"products": [{ "id": "Ca", "count": 1 }, { "id": "Cl", "count": 2 }],
|
||||||
|
"conditions": { "requiresEnergy": true },
|
||||||
|
"energyChange": 80,
|
||||||
|
"description": "Electrolysis of molten CaCl₂ → pure calcium metal",
|
||||||
|
"descriptionRu": "Электролиз расплава CaCl₂ → чистый металлический кальций",
|
||||||
|
"difficulty": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "decomp_al2o3", "type": "decomposition",
|
||||||
|
"reactants": [{ "id": "Al2O3", "count": 1 }],
|
||||||
|
"products": [{ "id": "Al", "count": 2 }, { "id": "O", "count": 3 }],
|
||||||
|
"conditions": { "requiresEnergy": true },
|
||||||
|
"energyChange": 84,
|
||||||
|
"description": "Hall-Héroult process: alumina electrolysis → aluminum. Most energy-intensive industrial process",
|
||||||
|
"descriptionRu": "Процесс Холла-Эру: электролиз глинозёма → алюминий. Самый энергоёмкий промышленный процесс",
|
||||||
|
"difficulty": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "decomp_fecl3", "type": "decomposition",
|
||||||
|
"reactants": [{ "id": "FeCl3", "count": 1 }],
|
||||||
|
"products": [{ "id": "Fe", "count": 1 }, { "id": "Cl", "count": 3 }],
|
||||||
|
"conditions": { "minTemp": 1000 },
|
||||||
|
"energyChange": 40,
|
||||||
|
"description": "Iron(III) chloride thermal decomposition → iron + chlorine gas",
|
||||||
|
"descriptionRu": "Термическое разложение хлорида железа(III) → железо + хлор",
|
||||||
|
"difficulty": 3
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": "redox_cr2o3_al", "type": "redox",
|
||||||
|
"reactants": [{ "id": "Cr2O3", "count": 1 }, { "id": "Al", "count": 2 }],
|
||||||
|
"products": [{ "id": "Cr", "count": 2 }, { "id": "Al2O3", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 1000 },
|
||||||
|
"energyChange": -54,
|
||||||
|
"description": "Chromium thermite: aluminium reduces chromium oxide. Used to produce pure chromium",
|
||||||
|
"descriptionRu": "Хромовый термит: алюминий восстанавливает оксид хрома. Производство чистого хрома",
|
||||||
|
"difficulty": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "redox_wo3_al", "type": "redox",
|
||||||
|
"reactants": [{ "id": "WO3", "count": 1 }, { "id": "Al", "count": 2 }],
|
||||||
|
"products": [{ "id": "W", "count": 1 }, { "id": "Al2O3", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 1000 },
|
||||||
|
"energyChange": -70,
|
||||||
|
"description": "Tungsten extraction: aluminum reduces tungsten oxide to pure metal",
|
||||||
|
"descriptionRu": "Извлечение вольфрама: алюминий восстанавливает оксид вольфрама до чистого металла",
|
||||||
|
"difficulty": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "redox_cuo_c", "type": "redox",
|
||||||
|
"reactants": [{ "id": "CuO", "count": 1 }, { "id": "C", "count": 1 }],
|
||||||
|
"products": [{ "id": "Cu", "count": 1 }, { "id": "CO", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 1000 },
|
||||||
|
"energyChange": -13,
|
||||||
|
"description": "Ancient copper smelting: carbon reduces copper ore to pure copper",
|
||||||
|
"descriptionRu": "Древняя медеплавка: углерод восстанавливает медную руду до чистой меди",
|
||||||
|
"difficulty": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "redox_pbo_c", "type": "redox",
|
||||||
|
"reactants": [{ "id": "PbO", "count": 1 }, { "id": "C", "count": 1 }],
|
||||||
|
"products": [{ "id": "Pb", "count": 1 }, { "id": "CO", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 1000 },
|
||||||
|
"energyChange": -11,
|
||||||
|
"description": "Lead smelting: carbon reduces lead oxide to metallic lead",
|
||||||
|
"descriptionRu": "Свинцовая плавка: углерод восстанавливает оксид свинца до металлического свинца",
|
||||||
|
"difficulty": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "redox_fe2o3_c", "type": "redox",
|
||||||
|
"reactants": [{ "id": "Fe2O3", "count": 1 }, { "id": "C", "count": 3 }],
|
||||||
|
"products": [{ "id": "Fe", "count": 2 }, { "id": "CO", "count": 3 }],
|
||||||
|
"conditions": { "minTemp": 1000 },
|
||||||
|
"energyChange": -49,
|
||||||
|
"description": "Iron smelting: carbon reduces iron ore. Foundation of civilization",
|
||||||
|
"descriptionRu": "Выплавка железа: углерод восстанавливает железную руду. Основа цивилизации",
|
||||||
|
"difficulty": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "redox_coo_c", "type": "redox",
|
||||||
|
"reactants": [{ "id": "CoO", "count": 1 }, { "id": "C", "count": 1 }],
|
||||||
|
"products": [{ "id": "Co", "count": 1 }, { "id": "CO", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 1000 },
|
||||||
|
"energyChange": -10,
|
||||||
|
"description": "Carbon reduces cobalt oxide → pure cobalt. Blue pigment source",
|
||||||
|
"descriptionRu": "Углерод восстанавливает оксид кобальта → чистый кобальт. Источник синего пигмента",
|
||||||
|
"difficulty": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "redox_fe2o3_co", "type": "redox",
|
||||||
|
"reactants": [{ "id": "Fe2O3", "count": 1 }, { "id": "CO", "count": 3 }],
|
||||||
|
"products": [{ "id": "Fe", "count": 2 }, { "id": "CO2", "count": 3 }],
|
||||||
|
"conditions": { "minTemp": 1000 },
|
||||||
|
"energyChange": -25,
|
||||||
|
"description": "Blast furnace: CO reduces iron ore to iron + CO₂. Industrial steelmaking",
|
||||||
|
"descriptionRu": "Доменная печь: CO восстанавливает железную руду до железа + CO₂. Промышленная металлургия",
|
||||||
|
"difficulty": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "redox_cuo_h", "type": "redox",
|
||||||
|
"reactants": [{ "id": "CuO", "count": 1 }, { "id": "H", "count": 2 }],
|
||||||
|
"products": [{ "id": "Cu", "count": 1 }, { "id": "H2O", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 500 },
|
||||||
|
"energyChange": -13,
|
||||||
|
"description": "Hydrogen reduces copper oxide → shiny red copper appears + water",
|
||||||
|
"descriptionRu": "Водород восстанавливает оксид меди → появляется блестящая красная медь + вода",
|
||||||
|
"difficulty": 3
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": "ferment_glucose", "type": "decomposition",
|
||||||
|
"reactants": [{ "id": "C6H12O6", "count": 1 }],
|
||||||
|
"products": [{ "id": "C2H5OH", "count": 2 }, { "id": "CO2", "count": 2 }],
|
||||||
|
"energyChange": -7,
|
||||||
|
"description": "Fermentation: yeast converts glucose → ethanol + CO₂. Birth of brewing and baking",
|
||||||
|
"descriptionRu": "Брожение: дрожжи превращают глюкозу → этанол + CO₂. Рождение пивоварения и хлебопечения",
|
||||||
|
"difficulty": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "decomp_ch3cooh", "type": "decomposition",
|
||||||
|
"reactants": [{ "id": "CH3COOH", "count": 1 }],
|
||||||
|
"products": [{ "id": "C", "count": 2 }, { "id": "H", "count": 4 }, { "id": "O", "count": 2 }],
|
||||||
|
"conditions": { "minTemp": 1000 },
|
||||||
|
"energyChange": 5,
|
||||||
|
"description": "Thermal decomposition of acetic acid at extreme heat",
|
||||||
|
"descriptionRu": "Термическое разложение уксусной кислоты при экстремальном нагреве",
|
||||||
|
"difficulty": 3
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
113
src/data/schools.json
Normal file
113
src/data/schools.json
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "alchemist",
|
||||||
|
"name": "Alchemist",
|
||||||
|
"nameRu": "Алхимик",
|
||||||
|
"description": "Master of chemical transformations. Starts with reactive elements and knowledge of chemical equilibrium.",
|
||||||
|
"descriptionRu": "Мастер химических превращений. Начинает с реактивными элементами и знанием химического равновесия.",
|
||||||
|
"startingElements": ["H", "O", "C", "Na", "S", "Fe"],
|
||||||
|
"startingQuantities": {
|
||||||
|
"H": 5,
|
||||||
|
"O": 5,
|
||||||
|
"C": 3,
|
||||||
|
"Na": 3,
|
||||||
|
"S": 2,
|
||||||
|
"Fe": 2
|
||||||
|
},
|
||||||
|
"principle": "Chemical Equilibrium",
|
||||||
|
"principleRu": "Химическое равновесие",
|
||||||
|
"playstyle": "Potions, explosives, poisons. Combine elements for powerful chemical effects.",
|
||||||
|
"playstyleRu": "Зельеварение, взрывчатка, яды. Комбинируй элементы для мощных химических эффектов.",
|
||||||
|
"color": "#00ff88",
|
||||||
|
"bonuses": {
|
||||||
|
"reactionEfficiency": 1.25
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mechanic",
|
||||||
|
"name": "Mechanic",
|
||||||
|
"nameRu": "Механик",
|
||||||
|
"description": "Builder of mechanisms and contraptions. Uses leverage and momentum to amplify force.",
|
||||||
|
"descriptionRu": "Строитель механизмов и устройств. Использует рычаг и момент силы для усиления воздействия.",
|
||||||
|
"startingElements": ["Fe", "Cu", "Sn", "Si", "C"],
|
||||||
|
"startingQuantities": {
|
||||||
|
"Fe": 5,
|
||||||
|
"Cu": 4,
|
||||||
|
"Sn": 3,
|
||||||
|
"Si": 4,
|
||||||
|
"C": 4
|
||||||
|
},
|
||||||
|
"principle": "Lever & Moment of Force",
|
||||||
|
"principleRu": "Рычаг и момент силы",
|
||||||
|
"playstyle": "Craft mechanisms, traps, automatons. Metals are your tools.",
|
||||||
|
"playstyleRu": "Крафт механизмов, ловушки, автоматоны. Металлы — твои инструменты.",
|
||||||
|
"color": "#ff8800",
|
||||||
|
"bonuses": {
|
||||||
|
"projectileDamage": 1.3
|
||||||
|
},
|
||||||
|
"unlockCondition": {
|
||||||
|
"type": "elements_discovered",
|
||||||
|
"threshold": 10,
|
||||||
|
"hint": "Discover 10 elements in the Codex",
|
||||||
|
"hintRu": "Открой 10 элементов в Кодексе"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "naturalist",
|
||||||
|
"name": "Naturalist",
|
||||||
|
"nameRu": "Натуралист",
|
||||||
|
"description": "Observer of living systems. Understands photosynthesis and ecosystem balance.",
|
||||||
|
"descriptionRu": "Наблюдатель живых систем. Понимает фотосинтез и баланс экосистем.",
|
||||||
|
"startingElements": ["C", "N", "O", "P", "K"],
|
||||||
|
"startingQuantities": {
|
||||||
|
"C": 5,
|
||||||
|
"N": 4,
|
||||||
|
"O": 5,
|
||||||
|
"P": 3,
|
||||||
|
"K": 3
|
||||||
|
},
|
||||||
|
"principle": "Photosynthesis",
|
||||||
|
"principleRu": "Фотосинтез",
|
||||||
|
"playstyle": "Taming, cultivation, ecosystem control. Work with nature, not against it.",
|
||||||
|
"playstyleRu": "Приручение, выращивание, экосистемный контроль. Работай с природой, а не против неё.",
|
||||||
|
"color": "#44cc44",
|
||||||
|
"bonuses": {
|
||||||
|
"creatureAggroRange": 0.6
|
||||||
|
},
|
||||||
|
"unlockCondition": {
|
||||||
|
"type": "creatures_discovered",
|
||||||
|
"threshold": 3,
|
||||||
|
"hint": "Discover 3 creature species",
|
||||||
|
"hintRu": "Открой 3 вида существ"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "navigator",
|
||||||
|
"name": "Navigator",
|
||||||
|
"nameRu": "Навигатор",
|
||||||
|
"description": "Master of angles and distances. Charts the unknown with precision and moves with unmatched speed.",
|
||||||
|
"descriptionRu": "Мастер углов и расстояний. Исследует неизведанное с точностью и двигается с непревзойдённой скоростью.",
|
||||||
|
"startingElements": ["Si", "Fe", "C", "H", "O"],
|
||||||
|
"startingQuantities": {
|
||||||
|
"Si": 4,
|
||||||
|
"Fe": 3,
|
||||||
|
"C": 4,
|
||||||
|
"H": 5,
|
||||||
|
"O": 4
|
||||||
|
},
|
||||||
|
"principle": "Angular Measurement",
|
||||||
|
"principleRu": "Угловое измерение",
|
||||||
|
"playstyle": "Cartography, scouting, mobility. See further, move faster.",
|
||||||
|
"playstyleRu": "Картография, разведка, мобильность. Видь дальше, двигайся быстрее.",
|
||||||
|
"color": "#4488ff",
|
||||||
|
"bonuses": {
|
||||||
|
"movementSpeed": 1.2
|
||||||
|
},
|
||||||
|
"unlockCondition": {
|
||||||
|
"type": "runs_completed",
|
||||||
|
"threshold": 3,
|
||||||
|
"hint": "Complete 3 runs",
|
||||||
|
"hintRu": "Заверши 3 рана"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -65,6 +65,11 @@ export class PhaserBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get the Phaser sprite for an entity (undefined if not yet rendered) */
|
||||||
|
getSprite(eid: number): Phaser.GameObjects.Arc | undefined {
|
||||||
|
return this.spriteMap.get(eid);
|
||||||
|
}
|
||||||
|
|
||||||
/** Current number of rendered entities */
|
/** Current number of rendered entities */
|
||||||
get entityCount(): number {
|
get entityCount(): number {
|
||||||
return this.spriteMap.size;
|
return this.spriteMap.size;
|
||||||
|
|||||||
@@ -34,3 +34,86 @@ export const Health = {
|
|||||||
export const ChemicalComposition = {
|
export const ChemicalComposition = {
|
||||||
primaryElement: [] as number[], // atomic number (e.g. 11 for Na)
|
primaryElement: [] as number[], // atomic number (e.g. 11 for Na)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Tag component — marks entity as the player (no data, identity only) */
|
||||||
|
export const PlayerTag = {
|
||||||
|
_tag: [] as number[],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Harvestable resource — mineral veins, geysers, etc. */
|
||||||
|
export const Resource = {
|
||||||
|
quantity: [] as number[], // remaining items to collect
|
||||||
|
interactRange: [] as number[], // max interaction distance in pixels
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Thrown element/compound projectile */
|
||||||
|
export const Projectile = {
|
||||||
|
lifetime: [] as number[], // remaining lifetime in ms (removed at 0)
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Creature Components ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Tag + species identifier for creatures */
|
||||||
|
export const Creature = {
|
||||||
|
speciesId: [] as number[], // SpeciesId enum value (0=Crystallid, 1=Acidophile, 2=Reagent)
|
||||||
|
};
|
||||||
|
|
||||||
|
/** AI finite state machine */
|
||||||
|
export const AI = {
|
||||||
|
state: [] as number[], // AIState enum value
|
||||||
|
stateTimer: [] as number[], // ms remaining in current state
|
||||||
|
targetEid: [] as number[], // entity being pursued/fled (-1 = none)
|
||||||
|
homeX: [] as number[], // spawn/home position X
|
||||||
|
homeY: [] as number[], // spawn/home position Y
|
||||||
|
attackCooldown: [] as number[], // ms until next attack allowed
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Creature metabolism — energy, hunger, feeding */
|
||||||
|
export const Metabolism = {
|
||||||
|
energy: [] as number[], // current energy (0 = starving)
|
||||||
|
energyMax: [] as number[], // maximum energy capacity
|
||||||
|
drainRate: [] as number[], // energy lost per second
|
||||||
|
feedAmount: [] as number[], // energy gained per feeding action
|
||||||
|
hungerThreshold: [] as number[], // fraction (0-1) below which creature seeks food
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Life cycle stage tracking */
|
||||||
|
export const LifeCycle = {
|
||||||
|
stage: [] as number[], // LifeStage enum value (0=egg, 1=youth, 2=mature, 3=aging)
|
||||||
|
stageTimer: [] as number[], // ms remaining in current stage
|
||||||
|
age: [] as number[], // total age in ms
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Boss Components ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Boss entity tag + phase info for bridge rendering */
|
||||||
|
export const Boss = {
|
||||||
|
dataIndex: [] as number[], // index in bosses.json array
|
||||||
|
phase: [] as number[], // BossPhase enum value (synced from BossState)
|
||||||
|
cycleCount: [] as number[], // completed cycles (synced from BossState)
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── World Trace Components (Great Cycle) ────────────────────────
|
||||||
|
|
||||||
|
/** A trace left by a previous run — ruin, death marker, discovery site */
|
||||||
|
export const WorldTrace = {
|
||||||
|
traceType: [] as number[], // TraceType enum (0=death_site, 1=discovery_site)
|
||||||
|
sourceRunId: [] as number[], // which run left this trace
|
||||||
|
glowPhase: [] as number[], // animation phase for pulsing glow
|
||||||
|
interactRange: [] as number[], // interaction range in pixels
|
||||||
|
};
|
||||||
|
|
||||||
|
/** TraceType enum values */
|
||||||
|
export const TraceType = {
|
||||||
|
DeathSite: 0,
|
||||||
|
DiscoverySite: 1,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ─── Mycelium Components ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Fungal node — a point where the Mycelium surfaces on the map */
|
||||||
|
export const FungalNode = {
|
||||||
|
nodeIndex: [] as number[], // index in fungal node info list
|
||||||
|
glowPhase: [] as number[], // animation phase (radians)
|
||||||
|
interactRange: [] as number[], // max interaction distance in pixels
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
import { gameConfig } from './config';
|
import { gameConfig } from './config';
|
||||||
|
|
||||||
new Phaser.Game(gameConfig);
|
const game = new Phaser.Game(gameConfig);
|
||||||
|
|
||||||
|
// Expose for debug console access
|
||||||
|
(window as unknown as Record<string, unknown>).__game = game;
|
||||||
|
|||||||
227
src/mycelium/graph.ts
Normal file
227
src/mycelium/graph.ts
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
/**
|
||||||
|
* Mycelium Graph — persistent knowledge network
|
||||||
|
*
|
||||||
|
* The graph grows as the player deposits discoveries at fungal nodes.
|
||||||
|
* Nodes represent knowledge (elements, reactions, compounds, creatures).
|
||||||
|
* Edges represent relationships (e.g., elements that form a reaction).
|
||||||
|
*
|
||||||
|
* Strength increases with repeated deposits, decays slightly over time
|
||||||
|
* (knowledge fades if not reinforced).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { MyceliumGraphData, MyceliumNodeData, MyceliumEdgeData, RunState } from '../run/types';
|
||||||
|
import type { DepositResult } from './types';
|
||||||
|
|
||||||
|
/** Strength increment per deposit (diminishing returns via current strength) */
|
||||||
|
const STRENGTH_INCREMENT = 0.2;
|
||||||
|
/** Initial strength for a newly created node */
|
||||||
|
const INITIAL_STRENGTH = 0.3;
|
||||||
|
/** Edge weight for related discoveries */
|
||||||
|
const EDGE_WEIGHT = 0.5;
|
||||||
|
|
||||||
|
/** Create a new empty Mycelium graph */
|
||||||
|
export function createMyceliumGraph(): MyceliumGraphData {
|
||||||
|
return {
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
totalDeposits: 0,
|
||||||
|
totalExtractions: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deposit all discoveries from a run into the Mycelium graph.
|
||||||
|
*
|
||||||
|
* - Creates new nodes for unknown discoveries
|
||||||
|
* - Strengthens existing nodes for repeated discoveries
|
||||||
|
* - Creates edges between related knowledge (elements↔reactions, reactions↔compounds)
|
||||||
|
*/
|
||||||
|
export function depositKnowledge(
|
||||||
|
graph: MyceliumGraphData,
|
||||||
|
run: RunState,
|
||||||
|
): DepositResult {
|
||||||
|
let newNodes = 0;
|
||||||
|
let strengthened = 0;
|
||||||
|
let newEdges = 0;
|
||||||
|
|
||||||
|
const addedNodeIds: string[] = [];
|
||||||
|
|
||||||
|
// Deposit elements
|
||||||
|
for (const elemId of run.discoveries.elements) {
|
||||||
|
const nodeId = `element:${elemId}`;
|
||||||
|
const result = upsertNode(graph, nodeId, 'element', elemId, run.runId);
|
||||||
|
if (result === 'created') newNodes++;
|
||||||
|
else strengthened++;
|
||||||
|
addedNodeIds.push(nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deposit reactions
|
||||||
|
for (const reactionId of run.discoveries.reactions) {
|
||||||
|
const nodeId = `reaction:${reactionId}`;
|
||||||
|
const result = upsertNode(graph, nodeId, 'reaction', reactionId, run.runId);
|
||||||
|
if (result === 'created') newNodes++;
|
||||||
|
else strengthened++;
|
||||||
|
addedNodeIds.push(nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deposit compounds
|
||||||
|
for (const compoundId of run.discoveries.compounds) {
|
||||||
|
const nodeId = `compound:${compoundId}`;
|
||||||
|
const result = upsertNode(graph, nodeId, 'compound', compoundId, run.runId);
|
||||||
|
if (result === 'created') newNodes++;
|
||||||
|
else strengthened++;
|
||||||
|
addedNodeIds.push(nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deposit creatures
|
||||||
|
for (const creatureId of run.discoveries.creatures) {
|
||||||
|
const nodeId = `creature:${creatureId}`;
|
||||||
|
const result = upsertNode(graph, nodeId, 'creature', creatureId, run.runId);
|
||||||
|
if (result === 'created') newNodes++;
|
||||||
|
else strengthened++;
|
||||||
|
addedNodeIds.push(nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create edges between related discoveries
|
||||||
|
// Elements ↔ Reactions (elements that appear in a reaction key)
|
||||||
|
for (const reactionId of run.discoveries.reactions) {
|
||||||
|
const reactionNodeId = `reaction:${reactionId}`;
|
||||||
|
// Parse reaction key (e.g., "Na+Cl" → ["Na", "Cl"])
|
||||||
|
const reactants = reactionId.split('+');
|
||||||
|
for (const reactant of reactants) {
|
||||||
|
const elemNodeId = `element:${reactant}`;
|
||||||
|
if (getNode(graph, elemNodeId)) {
|
||||||
|
const added = upsertEdge(graph, elemNodeId, reactionNodeId);
|
||||||
|
if (added) newEdges++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reactions ↔ Compounds (reaction produces compound)
|
||||||
|
for (const reactionId of run.discoveries.reactions) {
|
||||||
|
const reactionNodeId = `reaction:${reactionId}`;
|
||||||
|
for (const compoundId of run.discoveries.compounds) {
|
||||||
|
const compoundNodeId = `compound:${compoundId}`;
|
||||||
|
const added = upsertEdge(graph, reactionNodeId, compoundNodeId);
|
||||||
|
if (added) newEdges++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
graph.totalDeposits++;
|
||||||
|
|
||||||
|
return { newNodes, strengthened, newEdges };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a node by its ID */
|
||||||
|
export function getNode(
|
||||||
|
graph: MyceliumGraphData,
|
||||||
|
nodeId: string,
|
||||||
|
): MyceliumNodeData | undefined {
|
||||||
|
return graph.nodes.find(n => n.id === nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get an edge between two nodes */
|
||||||
|
export function getEdge(
|
||||||
|
graph: MyceliumGraphData,
|
||||||
|
fromId: string,
|
||||||
|
toId: string,
|
||||||
|
): MyceliumEdgeData | undefined {
|
||||||
|
return graph.edges.find(e =>
|
||||||
|
(e.from === fromId && e.to === toId) ||
|
||||||
|
(e.from === toId && e.to === fromId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get all nodes of a specific type */
|
||||||
|
export function getNodesByType(
|
||||||
|
graph: MyceliumGraphData,
|
||||||
|
type: MyceliumNodeData['type'],
|
||||||
|
): MyceliumNodeData[] {
|
||||||
|
return graph.nodes.filter(n => n.type === type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get nodes connected to a given node (via edges) */
|
||||||
|
export function getConnectedNodes(
|
||||||
|
graph: MyceliumGraphData,
|
||||||
|
nodeId: string,
|
||||||
|
): MyceliumNodeData[] {
|
||||||
|
const connectedIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const edge of graph.edges) {
|
||||||
|
if (edge.from === nodeId) connectedIds.add(edge.to);
|
||||||
|
if (edge.to === nodeId) connectedIds.add(edge.from);
|
||||||
|
}
|
||||||
|
|
||||||
|
return graph.nodes.filter(n => connectedIds.has(n.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get summary statistics of the graph */
|
||||||
|
export function getGraphStats(graph: MyceliumGraphData): {
|
||||||
|
nodeCount: number;
|
||||||
|
edgeCount: number;
|
||||||
|
totalDeposits: number;
|
||||||
|
totalExtractions: number;
|
||||||
|
averageStrength: number;
|
||||||
|
} {
|
||||||
|
const avgStrength = graph.nodes.length > 0
|
||||||
|
? graph.nodes.reduce((sum, n) => sum + n.strength, 0) / graph.nodes.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodeCount: graph.nodes.length,
|
||||||
|
edgeCount: graph.edges.length,
|
||||||
|
totalDeposits: graph.totalDeposits,
|
||||||
|
totalExtractions: graph.totalExtractions,
|
||||||
|
averageStrength: avgStrength,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Internal helpers ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Insert or update a knowledge node. Returns 'created' or 'strengthened'. */
|
||||||
|
function upsertNode(
|
||||||
|
graph: MyceliumGraphData,
|
||||||
|
nodeId: string,
|
||||||
|
type: MyceliumNodeData['type'],
|
||||||
|
knowledgeId: string,
|
||||||
|
runId: number,
|
||||||
|
): 'created' | 'strengthened' {
|
||||||
|
const existing = getNode(graph, nodeId);
|
||||||
|
if (existing) {
|
||||||
|
// Diminishing returns: delta = INCREMENT * (1 - currentStrength)
|
||||||
|
const delta = STRENGTH_INCREMENT * (1 - existing.strength);
|
||||||
|
existing.strength = Math.min(1.0, existing.strength + delta);
|
||||||
|
return 'strengthened';
|
||||||
|
}
|
||||||
|
|
||||||
|
graph.nodes.push({
|
||||||
|
id: nodeId,
|
||||||
|
type,
|
||||||
|
knowledgeId,
|
||||||
|
depositedOnRun: runId,
|
||||||
|
strength: INITIAL_STRENGTH,
|
||||||
|
});
|
||||||
|
|
||||||
|
return 'created';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert or strengthen an edge. Returns true if newly created. */
|
||||||
|
function upsertEdge(
|
||||||
|
graph: MyceliumGraphData,
|
||||||
|
fromId: string,
|
||||||
|
toId: string,
|
||||||
|
): boolean {
|
||||||
|
const existing = getEdge(graph, fromId, toId);
|
||||||
|
if (existing) {
|
||||||
|
existing.weight = Math.min(1.0, existing.weight + 0.1);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
graph.edges.push({
|
||||||
|
from: fromId,
|
||||||
|
to: toId,
|
||||||
|
weight: EDGE_WEIGHT,
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
13
src/mycelium/index.ts
Normal file
13
src/mycelium/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Mycelium Module — the underground fungal network connecting runs
|
||||||
|
*
|
||||||
|
* Re-exports all public API from mycelium subsystems.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { createMyceliumGraph, depositKnowledge, getNode, getEdge, getNodesByType, getConnectedNodes, getGraphStats } from './graph';
|
||||||
|
export { extractMemoryFlashes, generateFlashText } from './knowledge';
|
||||||
|
export { createMycosisState, updateMycosis, getMycosisVisuals } from './mycosis';
|
||||||
|
export { getAvailableBonuses, purchaseBonus, canAffordBonus, resetShopSession } from './shop';
|
||||||
|
export { spawnFungalNodes } from './nodes';
|
||||||
|
export type { MyceliumKnowledgeNode, MyceliumEdge, MyceliumGraph, MemoryFlash, MycosisState, FungalNodeInfo, SporeBonus, BonusEffect, DepositResult, ExtractResult } from './types';
|
||||||
|
export type { MycosisVisuals } from './mycosis';
|
||||||
158
src/mycelium/knowledge.ts
Normal file
158
src/mycelium/knowledge.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* Knowledge System — deposit and extract memories via the Mycelium
|
||||||
|
*
|
||||||
|
* When a player interacts with a fungal node:
|
||||||
|
* - Deposit: current run discoveries are added to the Mycelium graph
|
||||||
|
* - Extract: memory flashes from past runs are surfaced
|
||||||
|
*
|
||||||
|
* Stronger nodes produce clearer, more useful memories.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { MyceliumGraphData, MyceliumNodeData } from '../run/types';
|
||||||
|
import type { MemoryFlash } from './types';
|
||||||
|
import myceliumData from '../data/mycelium.json';
|
||||||
|
|
||||||
|
const templates = myceliumData.memoryTemplates;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract memory flashes from the Mycelium graph.
|
||||||
|
*
|
||||||
|
* Selects nodes weighted by strength, generates flash text
|
||||||
|
* for each. Stronger nodes produce higher-clarity flashes.
|
||||||
|
*
|
||||||
|
* @param graph The persistent Mycelium graph
|
||||||
|
* @param maxFlashes Maximum number of flashes to return
|
||||||
|
* @returns Array of memory flashes
|
||||||
|
*/
|
||||||
|
export function extractMemoryFlashes(
|
||||||
|
graph: MyceliumGraphData,
|
||||||
|
maxFlashes: number,
|
||||||
|
): MemoryFlash[] {
|
||||||
|
if (graph.nodes.length === 0) return [];
|
||||||
|
|
||||||
|
graph.totalExtractions++;
|
||||||
|
|
||||||
|
// Weighted random selection: higher strength = more likely
|
||||||
|
const selected = weightedSelect(graph.nodes, maxFlashes);
|
||||||
|
|
||||||
|
return selected.map(node => {
|
||||||
|
const flash = generateFlashText(node.type, node.knowledgeId);
|
||||||
|
flash.sourceRunId = node.depositedOnRun;
|
||||||
|
flash.clarity = node.strength;
|
||||||
|
return flash;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a memory flash text from templates.
|
||||||
|
*
|
||||||
|
* Replaces {element}, {compound}, {creature} placeholders
|
||||||
|
* with the actual knowledge ID.
|
||||||
|
*/
|
||||||
|
export function generateFlashText(
|
||||||
|
type: MyceliumNodeData['type'],
|
||||||
|
knowledgeId: string,
|
||||||
|
): MemoryFlash {
|
||||||
|
const templateKey = getTemplateKey(type);
|
||||||
|
const templateList = templates[templateKey];
|
||||||
|
|
||||||
|
// Deterministic-ish template selection based on knowledge ID hash
|
||||||
|
const hash = simpleHash(knowledgeId);
|
||||||
|
const template = templateList[hash % templateList.length];
|
||||||
|
|
||||||
|
const text = replaceTokens(template.text, type, knowledgeId);
|
||||||
|
const textRu = replaceTokens(template.textRu, type, knowledgeId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: templateKey,
|
||||||
|
text,
|
||||||
|
textRu,
|
||||||
|
sourceRunId: 0,
|
||||||
|
clarity: 0.5,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Internal helpers ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Map node type to template key */
|
||||||
|
function getTemplateKey(type: MyceliumNodeData['type']): MemoryFlash['type'] {
|
||||||
|
switch (type) {
|
||||||
|
case 'element': return 'element_hint';
|
||||||
|
case 'reaction': return 'reaction_hint';
|
||||||
|
case 'compound': return 'reaction_hint'; // compounds use reaction templates
|
||||||
|
case 'creature': return 'creature_hint';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace template tokens with actual values */
|
||||||
|
function replaceTokens(
|
||||||
|
template: string,
|
||||||
|
type: MyceliumNodeData['type'],
|
||||||
|
knowledgeId: string,
|
||||||
|
): string {
|
||||||
|
return template
|
||||||
|
.replace('{element}', knowledgeId)
|
||||||
|
.replace('{compound}', knowledgeId)
|
||||||
|
.replace('{creature}', knowledgeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simple hash for deterministic template selection */
|
||||||
|
function simpleHash(str: string): number {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return Math.abs(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weighted random selection without replacement.
|
||||||
|
* Nodes with higher strength are more likely to be selected.
|
||||||
|
*/
|
||||||
|
function weightedSelect(
|
||||||
|
nodes: MyceliumNodeData[],
|
||||||
|
count: number,
|
||||||
|
): MyceliumNodeData[] {
|
||||||
|
if (nodes.length <= count) return [...nodes];
|
||||||
|
|
||||||
|
const result: MyceliumNodeData[] = [];
|
||||||
|
const used = new Set<number>();
|
||||||
|
|
||||||
|
// Build cumulative weight array
|
||||||
|
const weights = nodes.map(n => n.strength + 0.1); // small base weight
|
||||||
|
const totalWeight = weights.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
for (let i = 0; i < count && used.size < nodes.length; i++) {
|
||||||
|
let target = pseudoRandom(i + nodes.length) * totalWeight;
|
||||||
|
let idx = 0;
|
||||||
|
|
||||||
|
while (idx < nodes.length) {
|
||||||
|
if (!used.has(idx)) {
|
||||||
|
target -= weights[idx];
|
||||||
|
if (target <= 0) break;
|
||||||
|
}
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safety: if we went past the end, find first unused
|
||||||
|
if (idx >= nodes.length) {
|
||||||
|
idx = 0;
|
||||||
|
while (used.has(idx) && idx < nodes.length) idx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idx < nodes.length) {
|
||||||
|
result.push(nodes[idx]);
|
||||||
|
used.add(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pseudo-random 0–1 from integer seed (mulberry32-like) */
|
||||||
|
function pseudoRandom(seed: number): number {
|
||||||
|
let t = (seed + 0x6d2b79f5) | 0;
|
||||||
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||||
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
}
|
||||||
83
src/mycelium/mycosis.ts
Normal file
83
src/mycelium/mycosis.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Mycosis — visual distortion from prolonged contact with fungal nodes
|
||||||
|
*
|
||||||
|
* When the player stays near a fungal node, mycosis level increases.
|
||||||
|
* At the reveal threshold, hidden information becomes visible
|
||||||
|
* (stronger memory flashes, secret details about nearby creatures).
|
||||||
|
*
|
||||||
|
* Moving away from nodes causes mycosis to slowly decay.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { MycosisState } from './types';
|
||||||
|
import { MYCOSIS_CONFIG } from './types';
|
||||||
|
|
||||||
|
/** Create a fresh mycosis state (no fungal influence) */
|
||||||
|
export function createMycosisState(): MycosisState {
|
||||||
|
return {
|
||||||
|
level: 0,
|
||||||
|
exposure: 0,
|
||||||
|
revealing: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update mycosis state based on proximity to fungal nodes.
|
||||||
|
*
|
||||||
|
* @param state Current mycosis state (mutated in place)
|
||||||
|
* @param deltaMs Time elapsed since last update (ms)
|
||||||
|
* @param nearNode Whether the player is currently near a fungal node
|
||||||
|
*/
|
||||||
|
export function updateMycosis(
|
||||||
|
state: MycosisState,
|
||||||
|
deltaMs: number,
|
||||||
|
nearNode: boolean,
|
||||||
|
): void {
|
||||||
|
const deltaSec = deltaMs / 1000;
|
||||||
|
|
||||||
|
if (nearNode) {
|
||||||
|
// Build up mycosis
|
||||||
|
state.exposure += deltaMs;
|
||||||
|
state.level = Math.min(
|
||||||
|
MYCOSIS_CONFIG.maxLevel,
|
||||||
|
state.level + MYCOSIS_CONFIG.buildRate * deltaSec,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Decay mycosis
|
||||||
|
state.level = Math.max(
|
||||||
|
0,
|
||||||
|
state.level - MYCOSIS_CONFIG.decayRate * deltaSec,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update reveal state
|
||||||
|
state.revealing = state.level >= MYCOSIS_CONFIG.revealThreshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Visual parameters derived from mycosis level */
|
||||||
|
export interface MycosisVisuals {
|
||||||
|
/** Tint overlay color */
|
||||||
|
tintColor: number;
|
||||||
|
/** Tint overlay alpha (0 = invisible, maxTintAlpha = full) */
|
||||||
|
tintAlpha: number;
|
||||||
|
/** Strength of visual distortion (0 = none, 1 = maximum) */
|
||||||
|
distortionStrength: number;
|
||||||
|
/** Whether hidden info should be shown */
|
||||||
|
revealing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get visual parameters for rendering the mycosis effect.
|
||||||
|
*
|
||||||
|
* @param state Current mycosis state
|
||||||
|
* @returns Visual parameters for the renderer
|
||||||
|
*/
|
||||||
|
export function getMycosisVisuals(state: MycosisState): MycosisVisuals {
|
||||||
|
const fraction = state.level / MYCOSIS_CONFIG.maxLevel;
|
||||||
|
|
||||||
|
return {
|
||||||
|
tintColor: MYCOSIS_CONFIG.tintColor,
|
||||||
|
tintAlpha: fraction * MYCOSIS_CONFIG.maxTintAlpha,
|
||||||
|
distortionStrength: fraction,
|
||||||
|
revealing: state.revealing,
|
||||||
|
};
|
||||||
|
}
|
||||||
131
src/mycelium/nodes.ts
Normal file
131
src/mycelium/nodes.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* Fungal Node Spawning — places Mycelium surface points on the world map
|
||||||
|
*
|
||||||
|
* Fungal nodes are ECS entities placed on walkable tiles during world generation.
|
||||||
|
* They glow softly (bioluminescent green) and pulse gently.
|
||||||
|
* Players interact with them to deposit/extract knowledge.
|
||||||
|
*
|
||||||
|
* Placement uses a Poisson-disc-like approach for even spacing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { addEntity, addComponent } from 'bitecs';
|
||||||
|
import type { World } from '../ecs/world';
|
||||||
|
import { Position, FungalNode, SpriteRef } from '../ecs/components';
|
||||||
|
import type { BiomeData, TileGrid } from '../world/types';
|
||||||
|
import type { FungalNodeInfo } from './types';
|
||||||
|
import { FUNGAL_NODE_CONFIG } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawn fungal nodes on the world map.
|
||||||
|
*
|
||||||
|
* Algorithm:
|
||||||
|
* 1. Scan grid for tiles listed in FUNGAL_NODE_CONFIG.spawnOnTiles
|
||||||
|
* 2. Deterministically select candidate positions using seed
|
||||||
|
* 3. Filter by minimum spacing constraint
|
||||||
|
* 4. Create ECS entities at selected positions
|
||||||
|
*
|
||||||
|
* @returns Map of entity ID → FungalNodeInfo
|
||||||
|
*/
|
||||||
|
export function spawnFungalNodes(
|
||||||
|
world: World,
|
||||||
|
grid: TileGrid,
|
||||||
|
biome: BiomeData,
|
||||||
|
seed: number,
|
||||||
|
): Map<number, FungalNodeInfo> {
|
||||||
|
const nodeData = new Map<number, FungalNodeInfo>();
|
||||||
|
const tileSize = biome.tileSize;
|
||||||
|
|
||||||
|
// Find tile IDs that fungal nodes can spawn on
|
||||||
|
const spawnTileIds = new Set<number>();
|
||||||
|
for (const tileName of FUNGAL_NODE_CONFIG.spawnOnTiles) {
|
||||||
|
const tile = biome.tiles.find(t => t.name === tileName);
|
||||||
|
if (tile) spawnTileIds.add(tile.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all candidate positions (walkable tiles that match spawn targets)
|
||||||
|
const candidates: { x: number; y: number }[] = [];
|
||||||
|
for (let y = 0; y < grid.length; y++) {
|
||||||
|
for (let x = 0; x < grid[y].length; x++) {
|
||||||
|
if (spawnTileIds.has(grid[y][x])) {
|
||||||
|
candidates.push({ x, y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.length === 0) return nodeData;
|
||||||
|
|
||||||
|
// Determine target node count from map size
|
||||||
|
const mapArea = biome.mapWidth * biome.mapHeight;
|
||||||
|
const targetCount = Math.max(1, Math.round(mapArea * FUNGAL_NODE_CONFIG.targetDensity));
|
||||||
|
|
||||||
|
// Deterministic placement using seed-based scoring
|
||||||
|
const scored = candidates.map(c => ({
|
||||||
|
...c,
|
||||||
|
score: deterministicScore(c.x, c.y, seed),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Sort by score (highest first) — deterministic ordering
|
||||||
|
scored.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
|
// Select nodes respecting minimum spacing
|
||||||
|
const placed: { x: number; y: number }[] = [];
|
||||||
|
const minSpacing = FUNGAL_NODE_CONFIG.minSpacing;
|
||||||
|
|
||||||
|
for (const candidate of scored) {
|
||||||
|
if (placed.length >= targetCount) break;
|
||||||
|
|
||||||
|
// Check spacing against all placed nodes
|
||||||
|
let tooClose = false;
|
||||||
|
for (const existing of placed) {
|
||||||
|
const dx = candidate.x - existing.x;
|
||||||
|
const dy = candidate.y - existing.y;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (dist < minSpacing) {
|
||||||
|
tooClose = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tooClose) {
|
||||||
|
placed.push({ x: candidate.x, y: candidate.y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create ECS entities for placed nodes
|
||||||
|
for (let i = 0; i < placed.length; i++) {
|
||||||
|
const pos = placed[i];
|
||||||
|
const eid = addEntity(world);
|
||||||
|
|
||||||
|
addComponent(world, eid, Position);
|
||||||
|
addComponent(world, eid, FungalNode);
|
||||||
|
addComponent(world, eid, SpriteRef);
|
||||||
|
|
||||||
|
Position.x[eid] = pos.x * tileSize + tileSize / 2;
|
||||||
|
Position.y[eid] = pos.y * tileSize + tileSize / 2;
|
||||||
|
|
||||||
|
FungalNode.nodeIndex[eid] = i;
|
||||||
|
FungalNode.glowPhase[eid] = deterministicScore(pos.x, pos.y, seed + 1) * Math.PI * 2;
|
||||||
|
FungalNode.interactRange[eid] = FUNGAL_NODE_CONFIG.interactRange;
|
||||||
|
|
||||||
|
SpriteRef.color[eid] = FUNGAL_NODE_CONFIG.spriteColor;
|
||||||
|
SpriteRef.radius[eid] = FUNGAL_NODE_CONFIG.spriteRadius;
|
||||||
|
|
||||||
|
nodeData.set(eid, {
|
||||||
|
tileX: pos.x,
|
||||||
|
tileY: pos.y,
|
||||||
|
nodeIndex: i,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodeData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic score for a tile position (0–1).
|
||||||
|
* Higher scores are selected first for node placement.
|
||||||
|
*/
|
||||||
|
function deterministicScore(x: number, y: number, seed: number): number {
|
||||||
|
// Multiplicative hash for spatial distribution
|
||||||
|
const hash = ((x * 73856093) ^ (y * 19349663) ^ (seed * 83492791)) >>> 0;
|
||||||
|
return (hash % 1000000) / 1000000;
|
||||||
|
}
|
||||||
65
src/mycelium/shop.ts
Normal file
65
src/mycelium/shop.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Spore Shop — spend spores for starting bonuses at the Cradle
|
||||||
|
*
|
||||||
|
* Before each run, the player can visit fungal nodes in the Cradle
|
||||||
|
* to exchange spores for:
|
||||||
|
* - Extra health
|
||||||
|
* - Extra starting elements
|
||||||
|
* - Knowledge boost (clearer memory flashes)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { MetaState } from '../run/types';
|
||||||
|
import type { SporeBonus, BonusEffect } from './types';
|
||||||
|
import myceliumData from '../data/mycelium.json';
|
||||||
|
|
||||||
|
const allBonuses = myceliumData.sporeBonuses as SporeBonus[];
|
||||||
|
|
||||||
|
/** Track which non-repeatable bonuses have been purchased this session */
|
||||||
|
const purchasedNonRepeatableThisSession = new Set<string>();
|
||||||
|
|
||||||
|
/** Get all available spore bonuses */
|
||||||
|
export function getAvailableBonuses(): SporeBonus[] {
|
||||||
|
return [...allBonuses];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if the player can afford a specific bonus */
|
||||||
|
export function canAffordBonus(meta: MetaState, bonusId: string): boolean {
|
||||||
|
const bonus = allBonuses.find(b => b.id === bonusId);
|
||||||
|
if (!bonus) return false;
|
||||||
|
if (!bonus.repeatable && purchasedNonRepeatableThisSession.has(bonusId)) return false;
|
||||||
|
return meta.spores >= bonus.cost;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Purchase a spore bonus. Deducts spores from meta state.
|
||||||
|
*
|
||||||
|
* @returns The purchased effect, or null if purchase failed
|
||||||
|
*/
|
||||||
|
export function purchaseBonus(
|
||||||
|
meta: MetaState,
|
||||||
|
bonusId: string,
|
||||||
|
): BonusEffect | null {
|
||||||
|
const bonus = allBonuses.find(b => b.id === bonusId);
|
||||||
|
if (!bonus) return null;
|
||||||
|
|
||||||
|
// Check non-repeatable
|
||||||
|
if (!bonus.repeatable && purchasedNonRepeatableThisSession.has(bonusId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check affordability
|
||||||
|
if (meta.spores < bonus.cost) return null;
|
||||||
|
|
||||||
|
// Deduct and record
|
||||||
|
meta.spores -= bonus.cost;
|
||||||
|
if (!bonus.repeatable) {
|
||||||
|
purchasedNonRepeatableThisSession.add(bonusId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bonus.effect;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset purchased tracking (call at start of each Cradle visit) */
|
||||||
|
export function resetShopSession(): void {
|
||||||
|
purchasedNonRepeatableThisSession.clear();
|
||||||
|
}
|
||||||
165
src/mycelium/types.ts
Normal file
165
src/mycelium/types.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* Mycelium Types — the underground fungal network connecting runs
|
||||||
|
*
|
||||||
|
* The Mycelium is a persistent knowledge graph that grows across runs.
|
||||||
|
* Physical fungal nodes on the world map serve as access points.
|
||||||
|
* Players deposit discoveries and extract memories from past runs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── Persistent Graph (stored in MetaState) ─────────────────────
|
||||||
|
|
||||||
|
/** A knowledge node in the Mycelium graph */
|
||||||
|
export interface MyceliumKnowledgeNode {
|
||||||
|
/** Unique node ID (format: "type:id", e.g. "element:Na") */
|
||||||
|
id: string;
|
||||||
|
/** Knowledge category */
|
||||||
|
type: 'element' | 'reaction' | 'compound' | 'creature';
|
||||||
|
/** Reference to the specific knowledge item */
|
||||||
|
knowledgeId: string;
|
||||||
|
/** Run on which this was first deposited */
|
||||||
|
depositedOnRun: number;
|
||||||
|
/** Knowledge strength 0–1 (increases with repeated deposits) */
|
||||||
|
strength: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An edge connecting two knowledge nodes */
|
||||||
|
export interface MyceliumEdge {
|
||||||
|
/** Source node ID */
|
||||||
|
from: string;
|
||||||
|
/** Target node ID */
|
||||||
|
to: string;
|
||||||
|
/** Connection strength 0–1 */
|
||||||
|
weight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The full persistent Mycelium graph */
|
||||||
|
export interface MyceliumGraph {
|
||||||
|
/** Knowledge nodes */
|
||||||
|
nodes: MyceliumKnowledgeNode[];
|
||||||
|
/** Connections between nodes */
|
||||||
|
edges: MyceliumEdge[];
|
||||||
|
/** Total deposits across all runs */
|
||||||
|
totalDeposits: number;
|
||||||
|
/** Total extractions across all runs */
|
||||||
|
totalExtractions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Memory Flashes (retrieved from past runs) ──────────────────
|
||||||
|
|
||||||
|
/** A memory fragment surfaced by the Mycelium */
|
||||||
|
export interface MemoryFlash {
|
||||||
|
/** What kind of hint this provides */
|
||||||
|
type: 'element_hint' | 'reaction_hint' | 'creature_hint' | 'lore';
|
||||||
|
/** Display text for the player */
|
||||||
|
text: string;
|
||||||
|
/** Display text in Russian */
|
||||||
|
textRu: string;
|
||||||
|
/** Which run deposited this knowledge */
|
||||||
|
sourceRunId: number;
|
||||||
|
/** How clear/strong this memory is (0–1) */
|
||||||
|
clarity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mycosis (visual distortion state) ──────────────────────────
|
||||||
|
|
||||||
|
/** Player's current mycosis (fungal influence) state */
|
||||||
|
export interface MycosisState {
|
||||||
|
/** Current mycosis intensity 0–1 */
|
||||||
|
level: number;
|
||||||
|
/** Accumulated exposure time near fungal nodes (ms) */
|
||||||
|
exposure: number;
|
||||||
|
/** Whether hidden info is currently being revealed */
|
||||||
|
revealing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mycosis configuration constants */
|
||||||
|
export const MYCOSIS_CONFIG = {
|
||||||
|
/** How fast mycosis builds up (level per second of exposure) */
|
||||||
|
buildRate: 0.08,
|
||||||
|
/** How fast mycosis decays when away from nodes (level per second) */
|
||||||
|
decayRate: 0.03,
|
||||||
|
/** Mycosis level at which hidden information is revealed */
|
||||||
|
revealThreshold: 0.5,
|
||||||
|
/** Maximum mycosis level */
|
||||||
|
maxLevel: 1.0,
|
||||||
|
/** Tint color for mycosis visual effect (greenish-purple) */
|
||||||
|
tintColor: 0x6644aa,
|
||||||
|
/** Maximum tint alpha at full mycosis */
|
||||||
|
maxTintAlpha: 0.25,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ─── Fungal Nodes (world map entities) ──────────────────────────
|
||||||
|
|
||||||
|
/** String data for a fungal node entity (not stored in ECS) */
|
||||||
|
export interface FungalNodeInfo {
|
||||||
|
/** Tile position */
|
||||||
|
tileX: number;
|
||||||
|
tileY: number;
|
||||||
|
/** Index in the world's fungal node list */
|
||||||
|
nodeIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Configuration for fungal node spawning */
|
||||||
|
export const FUNGAL_NODE_CONFIG = {
|
||||||
|
/** Interaction range in pixels */
|
||||||
|
interactRange: 48,
|
||||||
|
/** Minimum distance between fungal nodes (in tiles) */
|
||||||
|
minSpacing: 8,
|
||||||
|
/** Target number of nodes per map (adjusted by map size) */
|
||||||
|
targetDensity: 0.003,
|
||||||
|
/** Tiles where fungal nodes can spawn */
|
||||||
|
spawnOnTiles: ['ground', 'scorched-earth'],
|
||||||
|
/** Base glow color (bioluminescent green) */
|
||||||
|
glowColor: 0x44ff88,
|
||||||
|
/** Glow pulse speed (radians per second) */
|
||||||
|
glowPulseSpeed: 1.5,
|
||||||
|
/** Base glow radius in pixels */
|
||||||
|
glowRadius: 6,
|
||||||
|
/** Sprite radius */
|
||||||
|
spriteRadius: 5,
|
||||||
|
/** Sprite color */
|
||||||
|
spriteColor: 0x33cc66,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ─── Spore Bonuses (Cradle shop) ─────────────────────────────────
|
||||||
|
|
||||||
|
/** A purchasable bonus at the Spore Cradle */
|
||||||
|
export interface SporeBonus {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
nameRu: string;
|
||||||
|
description: string;
|
||||||
|
descriptionRu: string;
|
||||||
|
/** Spore cost */
|
||||||
|
cost: number;
|
||||||
|
/** Effect applied at run start */
|
||||||
|
effect: BonusEffect;
|
||||||
|
/** Whether this bonus can be purchased multiple times per run */
|
||||||
|
repeatable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bonus effect types */
|
||||||
|
export type BonusEffect =
|
||||||
|
| { type: 'extra_health'; amount: number }
|
||||||
|
| { type: 'extra_element'; symbol: string; quantity: number }
|
||||||
|
| { type: 'knowledge_boost'; multiplier: number };
|
||||||
|
|
||||||
|
// ─── Deposit / Extract results ───────────────────────────────────
|
||||||
|
|
||||||
|
/** Result of depositing knowledge at a fungal node */
|
||||||
|
export interface DepositResult {
|
||||||
|
/** How many new nodes were added to the graph */
|
||||||
|
newNodes: number;
|
||||||
|
/** How many existing nodes were strengthened */
|
||||||
|
strengthened: number;
|
||||||
|
/** How many new edges were created */
|
||||||
|
newEdges: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of extracting knowledge from a fungal node */
|
||||||
|
export interface ExtractResult {
|
||||||
|
/** Memory flashes retrieved */
|
||||||
|
flashes: MemoryFlash[];
|
||||||
|
/** Whether mycosis was triggered/increased */
|
||||||
|
mycosisIncreased: boolean;
|
||||||
|
}
|
||||||
128
src/player/collision.ts
Normal file
128
src/player/collision.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* Tile Collision System
|
||||||
|
*
|
||||||
|
* Checks player position against tile grid after movement.
|
||||||
|
* Supports wall-sliding: if full movement is blocked, tries
|
||||||
|
* X-only or Y-only to let player slide along walls.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { query } from 'bitecs';
|
||||||
|
import type { World } from '../ecs/world';
|
||||||
|
import { Position, Velocity, PlayerTag } from '../ecs/components';
|
||||||
|
import type { TileGrid } from '../world/types';
|
||||||
|
import { PLAYER_COLLISION_RADIUS } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a pixel position falls on a walkable tile.
|
||||||
|
* Returns false for out-of-bounds positions.
|
||||||
|
*/
|
||||||
|
export function isTileWalkable(
|
||||||
|
px: number,
|
||||||
|
py: number,
|
||||||
|
grid: TileGrid,
|
||||||
|
tileSize: number,
|
||||||
|
walkable: Set<number>,
|
||||||
|
): boolean {
|
||||||
|
const tx = Math.floor(px / tileSize);
|
||||||
|
const ty = Math.floor(py / tileSize);
|
||||||
|
if (ty < 0 || ty >= grid.length || tx < 0 || tx >= (grid[0]?.length ?? 0)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return walkable.has(grid[ty][tx]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a circular area (4 corners of bounding box) is on walkable tiles.
|
||||||
|
* Uses AABB corners, not true circle — good enough for small radii.
|
||||||
|
*/
|
||||||
|
export function isAreaWalkable(
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
radius: number,
|
||||||
|
grid: TileGrid,
|
||||||
|
tileSize: number,
|
||||||
|
walkable: Set<number>,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
isTileWalkable(x - radius, y - radius, grid, tileSize, walkable) &&
|
||||||
|
isTileWalkable(x + radius, y - radius, grid, tileSize, walkable) &&
|
||||||
|
isTileWalkable(x - radius, y + radius, grid, tileSize, walkable) &&
|
||||||
|
isTileWalkable(x + radius, y + radius, grid, tileSize, walkable)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve position after movement with wall-sliding.
|
||||||
|
*
|
||||||
|
* Priority: full move → X-slide → Y-slide → stay put.
|
||||||
|
* This gives natural wall-sliding behavior.
|
||||||
|
*/
|
||||||
|
export function resolveCollision(
|
||||||
|
newX: number,
|
||||||
|
newY: number,
|
||||||
|
prevX: number,
|
||||||
|
prevY: number,
|
||||||
|
radius: number,
|
||||||
|
grid: TileGrid,
|
||||||
|
tileSize: number,
|
||||||
|
walkable: Set<number>,
|
||||||
|
): { x: number; y: number } {
|
||||||
|
// Full movement valid
|
||||||
|
if (isAreaWalkable(newX, newY, radius, grid, tileSize, walkable)) {
|
||||||
|
return { x: newX, y: newY };
|
||||||
|
}
|
||||||
|
// X-only: slide along Y wall
|
||||||
|
if (isAreaWalkable(newX, prevY, radius, grid, tileSize, walkable)) {
|
||||||
|
return { x: newX, y: prevY };
|
||||||
|
}
|
||||||
|
// Y-only: slide along X wall
|
||||||
|
if (isAreaWalkable(prevX, newY, radius, grid, tileSize, walkable)) {
|
||||||
|
return { x: prevX, y: newY };
|
||||||
|
}
|
||||||
|
// Can't move at all
|
||||||
|
return { x: prevX, y: prevY };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a Set of walkable tile IDs from tile data.
|
||||||
|
* Used to quickly check if a tile allows movement.
|
||||||
|
*/
|
||||||
|
export function buildWalkableSet(
|
||||||
|
tiles: ReadonlyArray<{ id: number; walkable: boolean }>,
|
||||||
|
): Set<number> {
|
||||||
|
const set = new Set<number>();
|
||||||
|
for (const tile of tiles) {
|
||||||
|
if (tile.walkable) set.add(tile.id);
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ECS system: resolve tile collisions for player entities.
|
||||||
|
*
|
||||||
|
* Runs AFTER movementSystem. Reconstructs pre-movement position
|
||||||
|
* from current position and velocity, then resolves collision.
|
||||||
|
*/
|
||||||
|
export function tileCollisionSystem(
|
||||||
|
world: World,
|
||||||
|
deltaMs: number,
|
||||||
|
grid: TileGrid,
|
||||||
|
tileSize: number,
|
||||||
|
walkable: Set<number>,
|
||||||
|
): void {
|
||||||
|
const dt = deltaMs / 1000;
|
||||||
|
for (const eid of query(world, [Position, Velocity, PlayerTag])) {
|
||||||
|
const curX = Position.x[eid];
|
||||||
|
const curY = Position.y[eid];
|
||||||
|
// Reconstruct pre-movement position (movementSystem did: pos += vel * dt)
|
||||||
|
const prevX = curX - Velocity.vx[eid] * dt;
|
||||||
|
const prevY = curY - Velocity.vy[eid] * dt;
|
||||||
|
|
||||||
|
const resolved = resolveCollision(
|
||||||
|
curX, curY, prevX, prevY,
|
||||||
|
PLAYER_COLLISION_RADIUS, grid, tileSize, walkable,
|
||||||
|
);
|
||||||
|
Position.x[eid] = resolved.x;
|
||||||
|
Position.y[eid] = resolved.y;
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/player/crafting.ts
Normal file
102
src/player/crafting.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Crafting System — Chemistry Engine Integration
|
||||||
|
*
|
||||||
|
* Combines items from inventory using the reaction engine.
|
||||||
|
* On success: consumes reagents, adds products.
|
||||||
|
* On failure: returns educational explanation of why it didn't work.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ReactionEngine } from '../chemistry/engine';
|
||||||
|
import type { ReactionConditions, ReactionData } from '../chemistry/types';
|
||||||
|
import type { Inventory } from './inventory';
|
||||||
|
|
||||||
|
/** What the player wants to combine */
|
||||||
|
export interface CraftInput {
|
||||||
|
id: string; // element symbol or compound id
|
||||||
|
count: number; // quantity to use
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of a crafting attempt */
|
||||||
|
export interface CraftResult {
|
||||||
|
success: boolean;
|
||||||
|
/** Products added to inventory (count may differ from reaction output if inventory full) */
|
||||||
|
products?: Array<{ id: string; count: number }>;
|
||||||
|
/** What was consumed from inventory */
|
||||||
|
consumedReactants?: CraftInput[];
|
||||||
|
/** Reaction data (for codex, description display) */
|
||||||
|
reaction?: ReactionData;
|
||||||
|
/** Why it failed (English) */
|
||||||
|
failureReason?: string;
|
||||||
|
/** Why it failed (Russian) */
|
||||||
|
failureReasonRu?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to craft from inventory items.
|
||||||
|
*
|
||||||
|
* 1. Checks inventory has all reagents
|
||||||
|
* 2. Runs reaction engine (O(1) lookup + condition check)
|
||||||
|
* 3. On success: consumes reagents, adds products
|
||||||
|
* 4. On failure: returns educational reason
|
||||||
|
*
|
||||||
|
* Reagents are only consumed on success — failed attempts are free.
|
||||||
|
*/
|
||||||
|
export function craftFromInventory(
|
||||||
|
inventory: Inventory,
|
||||||
|
reactants: CraftInput[],
|
||||||
|
conditions?: Partial<ReactionConditions>,
|
||||||
|
): CraftResult {
|
||||||
|
// Empty input
|
||||||
|
if (reactants.length === 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
failureReason: 'Select at least one substance to combine.',
|
||||||
|
failureReasonRu: 'Выберите хотя бы одно вещество для комбинирования.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Check inventory has all reagents
|
||||||
|
for (const r of reactants) {
|
||||||
|
const have = inventory.getCount(r.id);
|
||||||
|
if (have < r.count) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
failureReason: `Not enough ${r.id} — have ${have}, need ${r.count}.`,
|
||||||
|
failureReasonRu: `Недостаточно ${r.id} — есть ${have}, нужно ${r.count}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Run reaction engine
|
||||||
|
const result = ReactionEngine.react(
|
||||||
|
reactants.map(r => ({ id: r.id, count: r.count })),
|
||||||
|
conditions,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
failureReason: result.failureReason,
|
||||||
|
failureReasonRu: result.failureReasonRu,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Consume reagents
|
||||||
|
for (const r of reactants) {
|
||||||
|
inventory.removeItem(r.id, r.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Add products to inventory
|
||||||
|
const addedProducts: Array<{ id: string; count: number }> = [];
|
||||||
|
for (const p of result.products!) {
|
||||||
|
const added = inventory.addItem(p.id, p.count);
|
||||||
|
addedProducts.push({ id: p.id, count: added });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
products: addedProducts,
|
||||||
|
consumedReactants: reactants,
|
||||||
|
reaction: result.reaction,
|
||||||
|
};
|
||||||
|
}
|
||||||
31
src/player/factory.ts
Normal file
31
src/player/factory.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Player Entity Factory
|
||||||
|
*
|
||||||
|
* Creates the player entity with all required components:
|
||||||
|
* Position, Velocity, SpriteRef, Health, PlayerTag.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { addComponent } from 'bitecs';
|
||||||
|
import type { World } from '../ecs/world';
|
||||||
|
import { PlayerTag } from '../ecs/components';
|
||||||
|
import { createGameEntity } from '../ecs/factory';
|
||||||
|
import { PLAYER_COLOR, PLAYER_RADIUS, PLAYER_HEALTH } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the player entity at the given position.
|
||||||
|
* @returns entity ID (eid)
|
||||||
|
*/
|
||||||
|
export function createPlayerEntity(
|
||||||
|
world: World,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
): number {
|
||||||
|
const eid = createGameEntity(world, {
|
||||||
|
position: { x, y },
|
||||||
|
velocity: { vx: 0, vy: 0 },
|
||||||
|
health: { current: PLAYER_HEALTH, max: PLAYER_HEALTH },
|
||||||
|
sprite: { color: PLAYER_COLOR, radius: PLAYER_RADIUS },
|
||||||
|
});
|
||||||
|
addComponent(world, eid, PlayerTag);
|
||||||
|
return eid;
|
||||||
|
}
|
||||||
50
src/player/input.ts
Normal file
50
src/player/input.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Player Input System
|
||||||
|
*
|
||||||
|
* Reads InputState → sets player entity Velocity.
|
||||||
|
* Diagonal movement is normalized to prevent faster diagonal speed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { query } from 'bitecs';
|
||||||
|
import type { World } from '../ecs/world';
|
||||||
|
import { Velocity, PlayerTag } from '../ecs/components';
|
||||||
|
import type { InputState } from './types';
|
||||||
|
import { PLAYER_SPEED } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate velocity vector from input state (pure function).
|
||||||
|
* Diagonal movement is normalized so magnitude = speed.
|
||||||
|
*/
|
||||||
|
export function calculatePlayerVelocity(
|
||||||
|
input: InputState,
|
||||||
|
speed: number,
|
||||||
|
): { vx: number; vy: number } {
|
||||||
|
const { moveX, moveY } = input;
|
||||||
|
|
||||||
|
if (moveX === 0 && moveY === 0) {
|
||||||
|
return { vx: 0, vy: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize diagonal so total speed stays constant
|
||||||
|
const isDiagonal = moveX !== 0 && moveY !== 0;
|
||||||
|
const factor = isDiagonal ? 1 / Math.SQRT2 : 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
vx: moveX * speed * factor,
|
||||||
|
vy: moveY * speed * factor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ECS system: set player entity velocity from input.
|
||||||
|
* Only affects entities with PlayerTag + Velocity.
|
||||||
|
*
|
||||||
|
* @param speedMultiplier — optional multiplier on base PLAYER_SPEED (default 1.0)
|
||||||
|
*/
|
||||||
|
export function playerInputSystem(world: World, input: InputState, speedMultiplier = 1.0): void {
|
||||||
|
const vel = calculatePlayerVelocity(input, PLAYER_SPEED * speedMultiplier);
|
||||||
|
for (const eid of query(world, [Velocity, PlayerTag])) {
|
||||||
|
Velocity.vx[eid] = vel.vx;
|
||||||
|
Velocity.vy[eid] = vel.vy;
|
||||||
|
}
|
||||||
|
}
|
||||||
114
src/player/interaction.ts
Normal file
114
src/player/interaction.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* Resource Interaction System — Phase 4.3
|
||||||
|
*
|
||||||
|
* Handles player collecting elements from world resources
|
||||||
|
* (mineral veins, geysers). Press E near a resource to collect.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { query } from 'bitecs';
|
||||||
|
import type { World } from '../ecs/world';
|
||||||
|
import { Position, Resource, PlayerTag } from '../ecs/components';
|
||||||
|
import { removeGameEntity } from '../ecs/factory';
|
||||||
|
import type { Inventory } from './inventory';
|
||||||
|
|
||||||
|
/** Metadata for a resource entity (string data that can't go in bitECS arrays) */
|
||||||
|
export interface ResourceInfo {
|
||||||
|
itemId: string; // element symbol or compound id
|
||||||
|
tileX: number;
|
||||||
|
tileY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of an interaction attempt */
|
||||||
|
export interface InteractionResult {
|
||||||
|
type: 'collected' | 'depleted' | 'inventory_full' | 'nothing_nearby';
|
||||||
|
itemId?: string;
|
||||||
|
remaining?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Elements that mineral veins can yield */
|
||||||
|
export const MINERAL_ELEMENTS = ['Fe', 'Cu', 'Zn', 'Au', 'Sn'] as const;
|
||||||
|
|
||||||
|
/** Elements that geysers can yield */
|
||||||
|
export const GEYSER_ELEMENTS = ['S', 'H'] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic element picker based on tile position and seed.
|
||||||
|
* Same (x, y, seed) always gives the same element.
|
||||||
|
*/
|
||||||
|
export function pickResourceElement(
|
||||||
|
tileX: number,
|
||||||
|
tileY: number,
|
||||||
|
seed: number,
|
||||||
|
options: readonly string[],
|
||||||
|
): string {
|
||||||
|
// Multiplicative hash for spatial distribution
|
||||||
|
const hash = ((tileX * 73856093) ^ (tileY * 19349663) ^ (seed * 83492791)) >>> 0;
|
||||||
|
return options[hash % options.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interaction system — handles E-key resource collection.
|
||||||
|
*
|
||||||
|
* Finds closest resource in range, adds to inventory, decrements quantity.
|
||||||
|
* Returns null if E not pressed, or InteractionResult describing what happened.
|
||||||
|
*/
|
||||||
|
export function interactionSystem(
|
||||||
|
world: World,
|
||||||
|
justPressedInteract: boolean,
|
||||||
|
inventory: Inventory,
|
||||||
|
resourceData: Map<number, ResourceInfo>,
|
||||||
|
): InteractionResult | null {
|
||||||
|
if (!justPressedInteract) return null;
|
||||||
|
|
||||||
|
// Find player position
|
||||||
|
const players = query(world, [Position, PlayerTag]);
|
||||||
|
if (players.length === 0) return null;
|
||||||
|
const playerEid = players[0];
|
||||||
|
const px = Position.x[playerEid];
|
||||||
|
const py = Position.y[playerEid];
|
||||||
|
|
||||||
|
// Find closest resource in range
|
||||||
|
let closestEid: number | null = null;
|
||||||
|
let closestDist = Infinity;
|
||||||
|
|
||||||
|
for (const eid of query(world, [Position, Resource])) {
|
||||||
|
const dx = Position.x[eid] - px;
|
||||||
|
const dy = Position.y[eid] - py;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
const range = Resource.interactRange[eid];
|
||||||
|
|
||||||
|
if (dist <= range && dist < closestDist) {
|
||||||
|
closestEid = eid;
|
||||||
|
closestDist = dist;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (closestEid === null) {
|
||||||
|
return { type: 'nothing_nearby' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = resourceData.get(closestEid);
|
||||||
|
if (!info) return { type: 'nothing_nearby' };
|
||||||
|
|
||||||
|
// Try to add to inventory
|
||||||
|
const added = inventory.addItem(info.itemId, 1);
|
||||||
|
if (added === 0) {
|
||||||
|
return { type: 'inventory_full', itemId: info.itemId };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrement resource quantity
|
||||||
|
Resource.quantity[closestEid] -= 1;
|
||||||
|
|
||||||
|
if (Resource.quantity[closestEid] <= 0) {
|
||||||
|
// Resource depleted — remove entity
|
||||||
|
removeGameEntity(world, closestEid);
|
||||||
|
resourceData.delete(closestEid);
|
||||||
|
return { type: 'depleted', itemId: info.itemId };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'collected',
|
||||||
|
itemId: info.itemId,
|
||||||
|
remaining: Resource.quantity[closestEid],
|
||||||
|
};
|
||||||
|
}
|
||||||
149
src/player/inventory.ts
Normal file
149
src/player/inventory.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* Player Inventory — Weight-Based with Element Stacking
|
||||||
|
*
|
||||||
|
* Items are elements (by symbol) or compounds (by id).
|
||||||
|
* Weight = real atomic/molecular mass.
|
||||||
|
* Same items stack automatically (count increases).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ElementRegistry } from '../chemistry/elements';
|
||||||
|
import { CompoundRegistry } from '../chemistry/compounds';
|
||||||
|
|
||||||
|
/** A single inventory entry */
|
||||||
|
export interface InventoryItem {
|
||||||
|
readonly id: string;
|
||||||
|
readonly count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the mass of an item (element or compound).
|
||||||
|
* Returns atomic mass for elements, molecular mass for compounds, 0 for unknown.
|
||||||
|
*/
|
||||||
|
export function getItemMass(id: string): number {
|
||||||
|
const el = ElementRegistry.getBySymbol(id);
|
||||||
|
if (el) return el.atomicMass;
|
||||||
|
|
||||||
|
const comp = CompoundRegistry.getById(id);
|
||||||
|
if (comp) return comp.mass;
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weight-based inventory with slot limits and auto-stacking.
|
||||||
|
*
|
||||||
|
* Weight uses real atomic/molecular masses (AMU).
|
||||||
|
* Same items stack into a single slot.
|
||||||
|
*/
|
||||||
|
export class Inventory {
|
||||||
|
private counts = new Map<string, number>();
|
||||||
|
|
||||||
|
/** Maximum total weight (AMU) */
|
||||||
|
readonly maxWeight: number;
|
||||||
|
/** Maximum number of unique item types (slots) */
|
||||||
|
readonly maxSlots: number;
|
||||||
|
|
||||||
|
constructor(maxWeight = 500, maxSlots = 20) {
|
||||||
|
this.maxWeight = maxWeight;
|
||||||
|
this.maxSlots = maxSlots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add items to inventory.
|
||||||
|
* @returns actual count added (may be less if weight limit reached, 0 if impossible)
|
||||||
|
*/
|
||||||
|
addItem(id: string, count = 1): number {
|
||||||
|
if (count <= 0) return 0;
|
||||||
|
|
||||||
|
const mass = getItemMass(id);
|
||||||
|
if (mass <= 0) return 0;
|
||||||
|
|
||||||
|
// New item needs an available slot
|
||||||
|
const isNewItem = !this.counts.has(id);
|
||||||
|
if (isNewItem && this.counts.size >= this.maxSlots) return 0;
|
||||||
|
|
||||||
|
// Calculate how many can fit by weight
|
||||||
|
const currentWeight = this.getTotalWeight();
|
||||||
|
const spaceLeft = this.maxWeight - currentWeight;
|
||||||
|
const maxByWeight = Math.floor(spaceLeft / mass);
|
||||||
|
const actualAdd = Math.min(count, maxByWeight);
|
||||||
|
|
||||||
|
if (actualAdd <= 0) return 0;
|
||||||
|
|
||||||
|
const existing = this.counts.get(id) ?? 0;
|
||||||
|
this.counts.set(id, existing + actualAdd);
|
||||||
|
|
||||||
|
return actualAdd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove items from inventory.
|
||||||
|
* @returns actual count removed (may be less if not enough in stock)
|
||||||
|
*/
|
||||||
|
removeItem(id: string, count = 1): number {
|
||||||
|
if (count <= 0) return 0;
|
||||||
|
|
||||||
|
const current = this.counts.get(id) ?? 0;
|
||||||
|
if (current <= 0) return 0;
|
||||||
|
|
||||||
|
const actualRemove = Math.min(count, current);
|
||||||
|
const newCount = current - actualRemove;
|
||||||
|
|
||||||
|
if (newCount <= 0) {
|
||||||
|
this.counts.delete(id);
|
||||||
|
} else {
|
||||||
|
this.counts.set(id, newCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return actualRemove;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get item count (0 if not in inventory) */
|
||||||
|
getCount(id: string): number {
|
||||||
|
return this.counts.get(id) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if inventory has at least `count` of an item */
|
||||||
|
hasItem(id: string, count = 1): boolean {
|
||||||
|
return (this.counts.get(id) ?? 0) >= count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get all items as array of { id, count } */
|
||||||
|
getItems(): InventoryItem[] {
|
||||||
|
const items: InventoryItem[] = [];
|
||||||
|
for (const [id, count] of this.counts) {
|
||||||
|
items.push({ id, count });
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total weight of all items (sum of mass * count) */
|
||||||
|
getTotalWeight(): number {
|
||||||
|
let total = 0;
|
||||||
|
for (const [id, count] of this.counts) {
|
||||||
|
total += getItemMass(id) * count;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Weight of a single item stack */
|
||||||
|
getItemWeight(id: string): number {
|
||||||
|
const count = this.counts.get(id) ?? 0;
|
||||||
|
return getItemMass(id) * count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Number of unique item types (occupied slots) */
|
||||||
|
get slotCount(): number {
|
||||||
|
return this.counts.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether inventory has zero items */
|
||||||
|
isEmpty(): boolean {
|
||||||
|
return this.counts.size === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove all items */
|
||||||
|
clear(): void {
|
||||||
|
this.counts.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
141
src/player/projectile.ts
Normal file
141
src/player/projectile.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* Projectile System — Throw Elements & Compounds
|
||||||
|
*
|
||||||
|
* Player launches projectiles toward mouse cursor.
|
||||||
|
* Projectiles travel in a straight line, expire after a lifetime,
|
||||||
|
* and are destroyed on hitting non-walkable tiles.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { addEntity, addComponent, query } from 'bitecs';
|
||||||
|
import type { World } from '../ecs/world';
|
||||||
|
import { Position, Velocity, SpriteRef, Projectile } from '../ecs/components';
|
||||||
|
import { removeGameEntity } from '../ecs/factory';
|
||||||
|
import { isTileWalkable } from './collision';
|
||||||
|
import { ElementRegistry } from '../chemistry/elements';
|
||||||
|
import { CompoundRegistry } from '../chemistry/compounds';
|
||||||
|
import type { TileGrid } from '../world/types';
|
||||||
|
|
||||||
|
/** Extra data for a projectile entity (string data) */
|
||||||
|
export interface ProjectileData {
|
||||||
|
itemId: string; // element symbol or compound id
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Constants ===
|
||||||
|
|
||||||
|
/** Projectile flight speed in pixels per second */
|
||||||
|
export const PROJECTILE_SPEED = 350;
|
||||||
|
|
||||||
|
/** Projectile lifetime in milliseconds */
|
||||||
|
export const PROJECTILE_LIFETIME = 2000;
|
||||||
|
|
||||||
|
/** Projectile visual radius */
|
||||||
|
export const PROJECTILE_RADIUS = 5;
|
||||||
|
|
||||||
|
// === Direction ===
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate normalized direction from source to target.
|
||||||
|
* Returns (1, 0) if source == target (default: right).
|
||||||
|
*/
|
||||||
|
export function calculateDirection(
|
||||||
|
fromX: number, fromY: number,
|
||||||
|
toX: number, toY: number,
|
||||||
|
): { dx: number; dy: number } {
|
||||||
|
const dx = toX - fromX;
|
||||||
|
const dy = toY - fromY;
|
||||||
|
const len = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (len < 0.001) {
|
||||||
|
return { dx: 1, dy: 0 }; // default direction
|
||||||
|
}
|
||||||
|
|
||||||
|
return { dx: dx / len, dy: dy / len };
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Launch ===
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the display color for a chemical item.
|
||||||
|
* Uses element color from registry, or compound color, or default white.
|
||||||
|
*/
|
||||||
|
function getItemColor(itemId: string): number {
|
||||||
|
const el = ElementRegistry.getBySymbol(itemId);
|
||||||
|
if (el) return parseInt(el.color.replace('#', ''), 16);
|
||||||
|
|
||||||
|
const comp = CompoundRegistry.getById(itemId);
|
||||||
|
if (comp) return parseInt(comp.color.replace('#', ''), 16);
|
||||||
|
|
||||||
|
return 0xffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a projectile entity flying from (fromX, fromY) toward (toX, toY).
|
||||||
|
* @returns entity ID
|
||||||
|
*/
|
||||||
|
export function launchProjectile(
|
||||||
|
world: World,
|
||||||
|
projData: Map<number, ProjectileData>,
|
||||||
|
fromX: number,
|
||||||
|
fromY: number,
|
||||||
|
toX: number,
|
||||||
|
toY: number,
|
||||||
|
itemId: string,
|
||||||
|
): number {
|
||||||
|
const dir = calculateDirection(fromX, fromY, toX, toY);
|
||||||
|
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Position);
|
||||||
|
addComponent(world, eid, Velocity);
|
||||||
|
addComponent(world, eid, SpriteRef);
|
||||||
|
addComponent(world, eid, Projectile);
|
||||||
|
|
||||||
|
Position.x[eid] = fromX;
|
||||||
|
Position.y[eid] = fromY;
|
||||||
|
Velocity.vx[eid] = dir.dx * PROJECTILE_SPEED;
|
||||||
|
Velocity.vy[eid] = dir.dy * PROJECTILE_SPEED;
|
||||||
|
SpriteRef.color[eid] = getItemColor(itemId);
|
||||||
|
SpriteRef.radius[eid] = PROJECTILE_RADIUS;
|
||||||
|
Projectile.lifetime[eid] = PROJECTILE_LIFETIME;
|
||||||
|
|
||||||
|
projData.set(eid, { itemId });
|
||||||
|
|
||||||
|
return eid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === System ===
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update projectiles: decrement lifetime, remove expired/collided.
|
||||||
|
* Runs AFTER movementSystem (projectiles moved by generic movement).
|
||||||
|
*/
|
||||||
|
export function projectileSystem(
|
||||||
|
world: World,
|
||||||
|
deltaMs: number,
|
||||||
|
grid: TileGrid,
|
||||||
|
tileSize: number,
|
||||||
|
walkable: Set<number>,
|
||||||
|
projData: Map<number, ProjectileData>,
|
||||||
|
): void {
|
||||||
|
const toRemove: number[] = [];
|
||||||
|
|
||||||
|
for (const eid of query(world, [Position, Projectile])) {
|
||||||
|
// Decrement lifetime
|
||||||
|
Projectile.lifetime[eid] -= deltaMs;
|
||||||
|
|
||||||
|
// Check expiry
|
||||||
|
if (Projectile.lifetime[eid] <= 0) {
|
||||||
|
toRemove.push(eid);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check tile collision (point check at projectile center)
|
||||||
|
if (!isTileWalkable(Position.x[eid], Position.y[eid], grid, tileSize, walkable)) {
|
||||||
|
toRemove.push(eid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const eid of toRemove) {
|
||||||
|
removeGameEntity(world, eid);
|
||||||
|
projData.delete(eid);
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/player/quickslots.ts
Normal file
66
src/player/quickslots.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Quick Slots — 4 Hotkey Slots for Fast Item Access
|
||||||
|
*
|
||||||
|
* Keys 1-4 select active slot. Active slot determines
|
||||||
|
* what gets thrown (F) or used. Items are referenced
|
||||||
|
* by id from the inventory.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Number of quick slots */
|
||||||
|
export const SLOT_COUNT = 4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick slots manager.
|
||||||
|
* Stores item ids assigned to 4 slots (keys 1-4).
|
||||||
|
* Active slot determines current "equipped" item.
|
||||||
|
*/
|
||||||
|
export class QuickSlots {
|
||||||
|
private slots: (string | null)[] = new Array(SLOT_COUNT).fill(null);
|
||||||
|
|
||||||
|
/** Currently active slot index (0-3) */
|
||||||
|
activeIndex = 0;
|
||||||
|
|
||||||
|
/** Assign an item id to a slot (null to clear) */
|
||||||
|
assign(index: number, itemId: string | null): void {
|
||||||
|
if (index < 0 || index >= SLOT_COUNT) return;
|
||||||
|
this.slots[index] = itemId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get item id in a slot (null if empty) */
|
||||||
|
getSlot(index: number): string | null {
|
||||||
|
if (index < 0 || index >= SLOT_COUNT) return null;
|
||||||
|
return this.slots[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get item id in the active slot */
|
||||||
|
getActive(): string | null {
|
||||||
|
return this.slots[this.activeIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set active slot index (clamped to valid range) */
|
||||||
|
setActive(index: number): void {
|
||||||
|
this.activeIndex = Math.max(0, Math.min(SLOT_COUNT - 1, index));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get all slot contents as array */
|
||||||
|
getAll(): (string | null)[] {
|
||||||
|
return [...this.slots];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-assign an item to the first empty slot.
|
||||||
|
* Returns slot index, or -1 if no empty slot or item already assigned.
|
||||||
|
*/
|
||||||
|
autoAssign(itemId: string): number {
|
||||||
|
// Don't duplicate
|
||||||
|
if (this.slots.includes(itemId)) return -1;
|
||||||
|
|
||||||
|
for (let i = 0; i < SLOT_COUNT; i++) {
|
||||||
|
if (this.slots[i] === null) {
|
||||||
|
this.slots[i] = itemId;
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/player/spawn.ts
Normal file
51
src/player/spawn.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Player Spawn Position
|
||||||
|
*
|
||||||
|
* Finds a walkable tile near the center of the map.
|
||||||
|
* Spirals outward from center to find the nearest valid spawn.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TileGrid } from '../world/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a walkable spawn position, starting from map center.
|
||||||
|
* Returns pixel coordinates (center of tile) or null if no walkable tile exists.
|
||||||
|
*/
|
||||||
|
export function findSpawnPosition(
|
||||||
|
grid: TileGrid,
|
||||||
|
tileSize: number,
|
||||||
|
walkable: Set<number>,
|
||||||
|
): { x: number; y: number } | null {
|
||||||
|
const height = grid.length;
|
||||||
|
if (height === 0) return null;
|
||||||
|
const width = grid[0].length;
|
||||||
|
if (width === 0) return null;
|
||||||
|
|
||||||
|
const centerY = Math.floor(height / 2);
|
||||||
|
const centerX = Math.floor(width / 2);
|
||||||
|
|
||||||
|
// Spiral outward from center
|
||||||
|
const maxRadius = Math.max(width, height);
|
||||||
|
for (let r = 0; r <= maxRadius; r++) {
|
||||||
|
for (let dy = -r; dy <= r; dy++) {
|
||||||
|
for (let dx = -r; dx <= r; dx++) {
|
||||||
|
// Only check perimeter of ring (interior was checked in smaller radius)
|
||||||
|
if (r > 0 && Math.abs(dx) < r && Math.abs(dy) < r) continue;
|
||||||
|
|
||||||
|
const ty = centerY + dy;
|
||||||
|
const tx = centerX + dx;
|
||||||
|
|
||||||
|
if (ty >= 0 && ty < height && tx >= 0 && tx < width) {
|
||||||
|
if (walkable.has(grid[ty][tx])) {
|
||||||
|
return {
|
||||||
|
x: tx * tileSize + tileSize / 2,
|
||||||
|
y: ty * tileSize + tileSize / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
33
src/player/types.ts
Normal file
33
src/player/types.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Player Types & Constants
|
||||||
|
*
|
||||||
|
* InputState represents the current frame's player input.
|
||||||
|
* Constants define player attributes (speed, size, health, color).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Keyboard/gamepad state for current frame */
|
||||||
|
export interface InputState {
|
||||||
|
/** Horizontal axis: -1 (left), 0 (none), +1 (right) */
|
||||||
|
moveX: number;
|
||||||
|
/** Vertical axis: -1 (up), 0 (none), +1 (down) */
|
||||||
|
moveY: number;
|
||||||
|
/** Interact key (E) pressed */
|
||||||
|
interact: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Player Constants ===
|
||||||
|
|
||||||
|
/** Movement speed in pixels per second */
|
||||||
|
export const PLAYER_SPEED = 150;
|
||||||
|
|
||||||
|
/** Collision radius in pixels (smaller than visual for forgiving feel) */
|
||||||
|
export const PLAYER_COLLISION_RADIUS = 6;
|
||||||
|
|
||||||
|
/** Starting and maximum health */
|
||||||
|
export const PLAYER_HEALTH = 100;
|
||||||
|
|
||||||
|
/** Visual color (bright cyan — stands out on dark biome) */
|
||||||
|
export const PLAYER_COLOR = 0x00e5ff;
|
||||||
|
|
||||||
|
/** Visual radius in pixels */
|
||||||
|
export const PLAYER_RADIUS = 10;
|
||||||
105
src/run/crisis.ts
Normal file
105
src/run/crisis.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* Crisis System — Chemical Plague
|
||||||
|
*
|
||||||
|
* When escalation reaches its threshold, a crisis activates.
|
||||||
|
* The Chemical Plague poisons the atmosphere — player must craft
|
||||||
|
* the correct neutralizing compound to stop it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CrisisConfig } from './types';
|
||||||
|
|
||||||
|
// ─── Chemical Plague Configuration ───────────────────────────────
|
||||||
|
|
||||||
|
export const CHEMICAL_PLAGUE: CrisisConfig = {
|
||||||
|
type: 'chemical-plague',
|
||||||
|
name: 'Chemical Plague',
|
||||||
|
nameRu: 'Химическая Чума',
|
||||||
|
description: 'A chain reaction is poisoning the atmosphere. Neutralize it with the right compound!',
|
||||||
|
descriptionRu: 'Цепная реакция отравляет атмосферу. Нейтрализуй правильным соединением!',
|
||||||
|
triggerThreshold: 0.8,
|
||||||
|
neutralizer: 'CaO', // Calcium oxide (quicklime) — absorbs acid gases
|
||||||
|
neutralizeAmount: 3, // need 3 units to fully neutralize
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Crisis State ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CrisisState {
|
||||||
|
config: CrisisConfig;
|
||||||
|
active: boolean;
|
||||||
|
/** Progress 0.0–1.0 — at 1.0 the world is fully poisoned */
|
||||||
|
progress: number;
|
||||||
|
resolved: boolean;
|
||||||
|
/** How many neutralizer units have been applied */
|
||||||
|
neutralizeApplied: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Damage growth rate per second during active crisis */
|
||||||
|
const CRISIS_DAMAGE_RATE = 0.01; // reaches 1.0 in ~100 seconds
|
||||||
|
|
||||||
|
/** Create a new crisis state from config */
|
||||||
|
export function createCrisisState(config: CrisisConfig): CrisisState {
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
active: true,
|
||||||
|
progress: 0,
|
||||||
|
resolved: false,
|
||||||
|
neutralizeApplied: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Advance crisis damage over time */
|
||||||
|
export function applyCrisisDamage(crisis: CrisisState, deltaMs: number): void {
|
||||||
|
if (!crisis.active || crisis.resolved) return;
|
||||||
|
const deltaSec = deltaMs / 1000;
|
||||||
|
crisis.progress = Math.min(1.0, crisis.progress + CRISIS_DAMAGE_RATE * deltaSec);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attempt to neutralize the crisis with a compound. Returns true if correct compound. */
|
||||||
|
export function attemptNeutralize(
|
||||||
|
crisis: CrisisState,
|
||||||
|
compoundId: string,
|
||||||
|
amount: number,
|
||||||
|
): boolean {
|
||||||
|
if (compoundId !== crisis.config.neutralizer) return false;
|
||||||
|
if (crisis.resolved) return true;
|
||||||
|
|
||||||
|
crisis.neutralizeApplied += amount;
|
||||||
|
|
||||||
|
// Reduce progress proportionally
|
||||||
|
const reductionPerUnit = 1.0 / crisis.config.neutralizeAmount;
|
||||||
|
crisis.progress = Math.max(0, crisis.progress - amount * reductionPerUnit);
|
||||||
|
|
||||||
|
// Check if fully neutralized
|
||||||
|
if (crisis.neutralizeApplied >= crisis.config.neutralizeAmount) {
|
||||||
|
crisis.resolved = true;
|
||||||
|
crisis.active = false;
|
||||||
|
crisis.progress = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if crisis has been resolved */
|
||||||
|
export function isCrisisResolved(crisis: CrisisState): boolean {
|
||||||
|
return crisis.resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calculate player damage per frame from active crisis */
|
||||||
|
export function getCrisisPlayerDamage(crisis: CrisisState, deltaMs: number): number {
|
||||||
|
if (crisis.resolved || crisis.progress <= 0.3) return 0;
|
||||||
|
const deltaSec = deltaMs / 1000;
|
||||||
|
// Damage scales with how far above 30% threshold
|
||||||
|
return (crisis.progress - 0.3) * 0.7 * deltaSec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get visual tint for crisis overlay */
|
||||||
|
export function getCrisisTint(crisis: CrisisState): { color: number; alpha: number } {
|
||||||
|
if (crisis.resolved || crisis.progress <= 0) {
|
||||||
|
return { color: 0x88ff88, alpha: 0 };
|
||||||
|
}
|
||||||
|
// Green toxic tint that intensifies with progress
|
||||||
|
return {
|
||||||
|
color: 0x88ff88,
|
||||||
|
alpha: Math.min(0.15, crisis.progress * 0.15),
|
||||||
|
};
|
||||||
|
}
|
||||||
220
src/run/cycle.ts
Normal file
220
src/run/cycle.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* Great Cycle System — 7-run macro cycles with traces between runs
|
||||||
|
*
|
||||||
|
* GDD spec (Section IV):
|
||||||
|
* - Every 7 runs = 1 Great Cycle
|
||||||
|
* - After 7th run → Great Renewal: world changes, lore unlocks, Mycelium matures
|
||||||
|
* - Previous 7 runs leave concrete traces: ruins, consequences, mutated creatures
|
||||||
|
* - Each cycle has a narrative theme (Awakening → Doubt → Realization → ...)
|
||||||
|
*
|
||||||
|
* Three Laws of Arcana:
|
||||||
|
* 1. Law of Return — everything that was, will be again, but never exactly the same
|
||||||
|
* 2. Law of Trace — nothing disappears without a trace
|
||||||
|
* 3. Law of Synthesis — when all knowledge fragments connect, the cycle transcends
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
GreatCycleState,
|
||||||
|
RunTrace,
|
||||||
|
RunState,
|
||||||
|
MetaState,
|
||||||
|
CycleTheme,
|
||||||
|
CycleWorldModifiers,
|
||||||
|
} from './types';
|
||||||
|
import { CYCLE_THEMES, RUNS_PER_CYCLE, RunPhase } from './types';
|
||||||
|
import { countDiscoveries } from './state';
|
||||||
|
|
||||||
|
// ─── Initialization ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Create initial great cycle state (first time playing) */
|
||||||
|
export function createGreatCycleState(): GreatCycleState {
|
||||||
|
return {
|
||||||
|
cycleNumber: 1,
|
||||||
|
runInCycle: 1,
|
||||||
|
theme: 'awakening',
|
||||||
|
currentCycleTraces: [],
|
||||||
|
previousCycleTraces: [],
|
||||||
|
renewalsCompleted: 0,
|
||||||
|
myceliumMaturation: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Theme Resolution ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Get the cycle theme for a given cycle number (1-based) */
|
||||||
|
export function getCycleTheme(cycleNumber: number): CycleTheme {
|
||||||
|
if (cycleNumber < 1) return 'awakening';
|
||||||
|
const idx = Math.min(cycleNumber - 1, CYCLE_THEMES.length - 1);
|
||||||
|
return CYCLE_THEMES[idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Run Trace Recording ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a RunTrace from a completed run state.
|
||||||
|
* Called when a run ends (death or boss victory).
|
||||||
|
*/
|
||||||
|
export function createRunTrace(
|
||||||
|
run: RunState,
|
||||||
|
cycleState: GreatCycleState,
|
||||||
|
): RunTrace {
|
||||||
|
// Extract up to 5 key element discoveries for trace markers
|
||||||
|
const keyElements = Array.from(run.discoveries.elements).slice(0, 5);
|
||||||
|
|
||||||
|
return {
|
||||||
|
runId: run.runId,
|
||||||
|
runInCycle: cycleState.runInCycle,
|
||||||
|
schoolId: run.schoolId,
|
||||||
|
biomeId: run.biomeId,
|
||||||
|
deathPosition: run.deathPosition,
|
||||||
|
phaseReached: run.phase,
|
||||||
|
crisisResolved: run.crisisResolved,
|
||||||
|
discoveryCount: countDiscoveries(run),
|
||||||
|
keyElements,
|
||||||
|
duration: run.elapsed,
|
||||||
|
worldSeed: run.worldSeed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a completed run's trace and advance the cycle.
|
||||||
|
* Returns true if this run triggers a Great Renewal (every 7th run).
|
||||||
|
*/
|
||||||
|
export function recordRunAndAdvanceCycle(
|
||||||
|
meta: MetaState,
|
||||||
|
run: RunState,
|
||||||
|
): boolean {
|
||||||
|
const cycle = meta.greatCycle;
|
||||||
|
const trace = createRunTrace(run, cycle);
|
||||||
|
|
||||||
|
// Add trace to current cycle
|
||||||
|
cycle.currentCycleTraces.push(trace);
|
||||||
|
|
||||||
|
// Check if this completes a great cycle
|
||||||
|
const isRenewal = cycle.runInCycle >= RUNS_PER_CYCLE;
|
||||||
|
|
||||||
|
if (isRenewal) {
|
||||||
|
// Great Renewal!
|
||||||
|
performGreatRenewal(meta);
|
||||||
|
} else {
|
||||||
|
// Advance to next run within the cycle
|
||||||
|
cycle.runInCycle += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isRenewal;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Great Renewal ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform the Great Renewal — transitions between great cycles.
|
||||||
|
*
|
||||||
|
* GDD spec:
|
||||||
|
* - World generation fundamentally changes
|
||||||
|
* - New lore layer unlocks
|
||||||
|
* - Mycelium "matures" — opens new capabilities
|
||||||
|
* - Previous 7 runs' traces become "previous cycle" traces
|
||||||
|
*/
|
||||||
|
export function performGreatRenewal(meta: MetaState): void {
|
||||||
|
const cycle = meta.greatCycle;
|
||||||
|
|
||||||
|
// Move current traces to previous (only keep last cycle's traces)
|
||||||
|
cycle.previousCycleTraces = [...cycle.currentCycleTraces];
|
||||||
|
cycle.currentCycleTraces = [];
|
||||||
|
|
||||||
|
// Advance cycle number
|
||||||
|
cycle.cycleNumber += 1;
|
||||||
|
cycle.runInCycle = 1;
|
||||||
|
cycle.theme = getCycleTheme(cycle.cycleNumber);
|
||||||
|
cycle.renewalsCompleted += 1;
|
||||||
|
|
||||||
|
// Mycelium maturation: each renewal strengthens the network
|
||||||
|
cycle.myceliumMaturation = Math.min(
|
||||||
|
cycle.myceliumMaturation + 0.15,
|
||||||
|
1.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Strengthen all Mycelium nodes on renewal
|
||||||
|
strengthenMyceliumOnRenewal(meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strengthen Mycelium nodes during Great Renewal.
|
||||||
|
* All nodes gain strength proportional to the maturation level.
|
||||||
|
*/
|
||||||
|
function strengthenMyceliumOnRenewal(meta: MetaState): void {
|
||||||
|
const bonus = 0.1 + meta.greatCycle.myceliumMaturation * 0.1;
|
||||||
|
for (const node of meta.mycelium.nodes) {
|
||||||
|
node.strength = Math.min(1.0, node.strength + bonus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── World Modifiers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get world generation modifiers based on cycle number.
|
||||||
|
* Higher cycles = more varied/challenging worlds.
|
||||||
|
*
|
||||||
|
* GDD: "Fundamentally changes generation (new biome types, different proportions)"
|
||||||
|
*/
|
||||||
|
export function getCycleWorldModifiers(cycleNumber: number): CycleWorldModifiers {
|
||||||
|
// Cycle 1 = baseline, each subsequent cycle adds variation
|
||||||
|
const t = Math.min((cycleNumber - 1) / 5, 1.0); // 0→1 over 5 cycles
|
||||||
|
|
||||||
|
return {
|
||||||
|
elevationScaleMultiplier: 1.0 + t * 0.3, // terrain becomes more varied
|
||||||
|
detailScaleMultiplier: 1.0 + t * 0.2, // more detail features
|
||||||
|
resourceDensityMultiplier: 1.0 + t * 0.15, // slightly more resources
|
||||||
|
creatureSpawnMultiplier: 1.0 + t * 0.25, // more creatures
|
||||||
|
escalationRateMultiplier: 1.0 + t * 0.2, // faster escalation
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Trace Queries ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all traces from the current and previous cycle for a given biome.
|
||||||
|
* Used by world generation to place ruins/markers.
|
||||||
|
*/
|
||||||
|
export function getTracesForBiome(
|
||||||
|
cycle: GreatCycleState,
|
||||||
|
biomeId: string,
|
||||||
|
): RunTrace[] {
|
||||||
|
const currentBiome = cycle.currentCycleTraces.filter(t => t.biomeId === biomeId);
|
||||||
|
const previousBiome = cycle.previousCycleTraces.filter(t => t.biomeId === biomeId);
|
||||||
|
return [...currentBiome, ...previousBiome];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get traces that left death markers (for ruin placement).
|
||||||
|
* Returns traces with valid death positions.
|
||||||
|
*/
|
||||||
|
export function getDeathTraces(traces: RunTrace[]): RunTrace[] {
|
||||||
|
return traces.filter(t => t.deathPosition !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current run is the last in the cycle (7th).
|
||||||
|
*/
|
||||||
|
export function isLastRunInCycle(cycle: GreatCycleState): boolean {
|
||||||
|
return cycle.runInCycle >= RUNS_PER_CYCLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a human-readable cycle summary for display.
|
||||||
|
*/
|
||||||
|
export function getCycleSummary(cycle: GreatCycleState): {
|
||||||
|
cycleNumber: number;
|
||||||
|
runInCycle: number;
|
||||||
|
totalRuns: number;
|
||||||
|
theme: CycleTheme;
|
||||||
|
isLastRun: boolean;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
cycleNumber: cycle.cycleNumber,
|
||||||
|
runInCycle: cycle.runInCycle,
|
||||||
|
totalRuns: RUNS_PER_CYCLE,
|
||||||
|
theme: cycle.theme,
|
||||||
|
isLastRun: isLastRunInCycle(cycle),
|
||||||
|
};
|
||||||
|
}
|
||||||
48
src/run/escalation.ts
Normal file
48
src/run/escalation.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Escalation Effects — how rising entropy changes the game world.
|
||||||
|
*
|
||||||
|
* GDD spec:
|
||||||
|
* "Entropy grows. Temperature fluctuates harder, chemical reactions become
|
||||||
|
* more unstable, creatures more aggressive, NPCs more paranoid."
|
||||||
|
*
|
||||||
|
* Escalation (0.0 → 1.0) modifies:
|
||||||
|
* - Creature speed, aggro range, attack damage
|
||||||
|
* - Reaction instability (chance of unexpected side effects)
|
||||||
|
* - Environmental damage (toxic atmosphere)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Multipliers and modifiers based on escalation level */
|
||||||
|
export interface EscalationEffects {
|
||||||
|
/** Creature movement speed multiplier (1.0 → 1.5) */
|
||||||
|
creatureSpeedMultiplier: number;
|
||||||
|
/** Creature aggro detection range multiplier (1.0 → 1.8) */
|
||||||
|
creatureAggroRange: number;
|
||||||
|
/** Creature attack damage multiplier (1.0 → 1.6) */
|
||||||
|
creatureAttackMultiplier: number;
|
||||||
|
/** Reaction instability: chance 0–0.3 of unexpected side effects */
|
||||||
|
reactionInstability: number;
|
||||||
|
/** Environmental damage per second at current escalation */
|
||||||
|
environmentalDamage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate escalation effects from escalation level (0.0–1.0).
|
||||||
|
* All effects scale linearly from neutral to maximum.
|
||||||
|
*/
|
||||||
|
export function getEscalationEffects(escalation: number): EscalationEffects {
|
||||||
|
// Clamp to [0, 1]
|
||||||
|
const t = Math.max(0, Math.min(1, escalation));
|
||||||
|
|
||||||
|
return {
|
||||||
|
creatureSpeedMultiplier: lerp(1.0, 1.5, t),
|
||||||
|
creatureAggroRange: lerp(1.0, 1.8, t),
|
||||||
|
creatureAttackMultiplier: lerp(1.0, 1.6, t),
|
||||||
|
reactionInstability: lerp(0, 0.3, t),
|
||||||
|
environmentalDamage: t > 0.6 ? lerp(0, 0.5, (t - 0.6) / 0.4) : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Linear interpolation */
|
||||||
|
function lerp(a: number, b: number, t: number): number {
|
||||||
|
return a + (b - a) * t;
|
||||||
|
}
|
||||||
179
src/run/meta.ts
Normal file
179
src/run/meta.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* Meta-Progression — persists between runs.
|
||||||
|
*
|
||||||
|
* Manages the Codex (permanent knowledge), spores (currency),
|
||||||
|
* unlocked schools, run history, and school unlock system.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { MetaState, CodexEntry, RunSummary, SchoolData, ResolvedSchoolBonuses } from './types';
|
||||||
|
import type { RunState } from './types';
|
||||||
|
import { DEFAULT_SCHOOL_BONUSES } from './types';
|
||||||
|
import { calculateSpores, countDiscoveries } from './state';
|
||||||
|
import { createGreatCycleState, recordRunAndAdvanceCycle } from './cycle';
|
||||||
|
import schoolsData from '../data/schools.json';
|
||||||
|
|
||||||
|
/** Create a fresh meta state (first time playing) */
|
||||||
|
export function createMetaState(): MetaState {
|
||||||
|
return {
|
||||||
|
spores: 0,
|
||||||
|
codex: [],
|
||||||
|
totalRuns: 0,
|
||||||
|
totalDeaths: 0,
|
||||||
|
unlockedSchools: ['alchemist'],
|
||||||
|
bestRunTime: 0,
|
||||||
|
bestRunDiscoveries: 0,
|
||||||
|
runHistory: [],
|
||||||
|
mycelium: { nodes: [], edges: [], totalDeposits: 0, totalExtractions: 0 },
|
||||||
|
greatCycle: createGreatCycleState(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply run results to meta state after a run ends (death or completion).
|
||||||
|
* Returns true if a Great Renewal was triggered.
|
||||||
|
*/
|
||||||
|
export function applyRunResults(meta: MetaState, run: RunState): boolean {
|
||||||
|
// 1. Calculate and add spores
|
||||||
|
const sporesEarned = calculateSpores(run);
|
||||||
|
meta.spores += sporesEarned;
|
||||||
|
|
||||||
|
// 2. Add new codex entries (skip duplicates)
|
||||||
|
const existingIds = new Set(meta.codex.map(e => `${e.type}:${e.id}`));
|
||||||
|
|
||||||
|
for (const elemId of run.discoveries.elements) {
|
||||||
|
const key = `element:${elemId}`;
|
||||||
|
if (!existingIds.has(key)) {
|
||||||
|
meta.codex.push({ id: elemId, type: 'element', discoveredOnRun: run.runId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const reactionId of run.discoveries.reactions) {
|
||||||
|
const key = `reaction:${reactionId}`;
|
||||||
|
if (!existingIds.has(key)) {
|
||||||
|
meta.codex.push({ id: reactionId, type: 'reaction', discoveredOnRun: run.runId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const compoundId of run.discoveries.compounds) {
|
||||||
|
const key = `compound:${compoundId}`;
|
||||||
|
if (!existingIds.has(key)) {
|
||||||
|
meta.codex.push({ id: compoundId, type: 'compound', discoveredOnRun: run.runId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const creatureId of run.discoveries.creatures) {
|
||||||
|
const key = `creature:${creatureId}`;
|
||||||
|
if (!existingIds.has(key)) {
|
||||||
|
meta.codex.push({ id: creatureId, type: 'creature', discoveredOnRun: run.runId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Update stats
|
||||||
|
meta.totalRuns += 1;
|
||||||
|
meta.totalDeaths += 1; // every run ends in death (for now)
|
||||||
|
|
||||||
|
const discoveryCount = countDiscoveries(run);
|
||||||
|
if (run.elapsed > meta.bestRunTime) {
|
||||||
|
meta.bestRunTime = run.elapsed;
|
||||||
|
}
|
||||||
|
if (discoveryCount > meta.bestRunDiscoveries) {
|
||||||
|
meta.bestRunDiscoveries = discoveryCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Add to run history (with cycle info)
|
||||||
|
const summary: RunSummary = {
|
||||||
|
runId: run.runId,
|
||||||
|
schoolId: run.schoolId,
|
||||||
|
duration: run.elapsed,
|
||||||
|
phase: run.phase,
|
||||||
|
discoveries: discoveryCount,
|
||||||
|
sporesEarned,
|
||||||
|
crisisResolved: run.crisisResolved,
|
||||||
|
biomeId: run.biomeId,
|
||||||
|
cycleNumber: meta.greatCycle.cycleNumber,
|
||||||
|
};
|
||||||
|
meta.runHistory.push(summary);
|
||||||
|
|
||||||
|
// 5. Record run trace and advance the great cycle
|
||||||
|
const isRenewal = recordRunAndAdvanceCycle(meta, run);
|
||||||
|
|
||||||
|
// 6. Check for school unlocks after updating codex and stats
|
||||||
|
checkSchoolUnlocks(meta);
|
||||||
|
|
||||||
|
return isRenewal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if a school is unlocked */
|
||||||
|
export function isSchoolUnlocked(meta: MetaState, schoolId: string): boolean {
|
||||||
|
return meta.unlockedSchools.includes(schoolId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get codex entries filtered by type */
|
||||||
|
export function getCodexEntries(
|
||||||
|
meta: MetaState,
|
||||||
|
type: CodexEntry['type'],
|
||||||
|
): CodexEntry[] {
|
||||||
|
return meta.codex.filter(e => e.type === type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get total codex entry count */
|
||||||
|
export function getCodexCount(meta: MetaState): number {
|
||||||
|
return meta.codex.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── School Unlock System ────────────────────────────────────────
|
||||||
|
|
||||||
|
const schools = schoolsData as unknown as SchoolData[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check all school unlock conditions against current meta state.
|
||||||
|
* Newly unlocked schools are added to meta.unlockedSchools.
|
||||||
|
*/
|
||||||
|
export function checkSchoolUnlocks(meta: MetaState): void {
|
||||||
|
for (const school of schools) {
|
||||||
|
// Skip already unlocked
|
||||||
|
if (meta.unlockedSchools.includes(school.id)) continue;
|
||||||
|
// Schools without condition are always available (e.g., alchemist)
|
||||||
|
if (!school.unlockCondition) {
|
||||||
|
meta.unlockedSchools.push(school.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const condition = school.unlockCondition;
|
||||||
|
let met = false;
|
||||||
|
|
||||||
|
switch (condition.type) {
|
||||||
|
case 'elements_discovered': {
|
||||||
|
const count = meta.codex.filter(e => e.type === 'element').length;
|
||||||
|
met = count >= condition.threshold;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'creatures_discovered': {
|
||||||
|
const count = meta.codex.filter(e => e.type === 'creature').length;
|
||||||
|
met = count >= condition.threshold;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'runs_completed': {
|
||||||
|
met = meta.totalRuns >= condition.threshold;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (met) {
|
||||||
|
meta.unlockedSchools.push(school.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── School Bonuses ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve school bonuses into a complete multiplier set.
|
||||||
|
* Unknown school ID returns all-default (1.0) bonuses.
|
||||||
|
*/
|
||||||
|
export function getSchoolBonuses(schoolId: string): ResolvedSchoolBonuses {
|
||||||
|
const school = schools.find(s => s.id === schoolId);
|
||||||
|
if (!school) return { ...DEFAULT_SCHOOL_BONUSES };
|
||||||
|
|
||||||
|
return {
|
||||||
|
...DEFAULT_SCHOOL_BONUSES,
|
||||||
|
...school.bonuses,
|
||||||
|
};
|
||||||
|
}
|
||||||
133
src/run/persistence.ts
Normal file
133
src/run/persistence.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* Persistence — IndexedDB storage for meta-progression.
|
||||||
|
*
|
||||||
|
* Saves/loads MetaState between browser sessions.
|
||||||
|
* Uses a simple key-value pattern with a single object store.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { MetaState, CodexEntry, RunSummary, MyceliumGraphData, GreatCycleState } from './types';
|
||||||
|
import { createMetaState } from './meta';
|
||||||
|
import { createGreatCycleState } from './cycle';
|
||||||
|
|
||||||
|
const DB_NAME = 'synthesis-meta';
|
||||||
|
const DB_VERSION = 1;
|
||||||
|
const STORE_NAME = 'meta';
|
||||||
|
const META_KEY = 'metaState';
|
||||||
|
|
||||||
|
/** Serializable version of MetaState (no Sets, plain objects) */
|
||||||
|
interface SerializedMetaState {
|
||||||
|
spores: number;
|
||||||
|
codex: CodexEntry[];
|
||||||
|
totalRuns: number;
|
||||||
|
totalDeaths: number;
|
||||||
|
unlockedSchools: string[];
|
||||||
|
bestRunTime: number;
|
||||||
|
bestRunDiscoveries: number;
|
||||||
|
runHistory: RunSummary[];
|
||||||
|
mycelium?: MyceliumGraphData;
|
||||||
|
greatCycle?: GreatCycleState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open (or create) the IndexedDB database */
|
||||||
|
function openDB(): Promise<IDBDatabase> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
|
||||||
|
request.onupgradeneeded = () => {
|
||||||
|
const db = request.result;
|
||||||
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||||
|
db.createObjectStore(STORE_NAME);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialize MetaState to plain JSON-safe object */
|
||||||
|
function serialize(meta: MetaState): SerializedMetaState {
|
||||||
|
return {
|
||||||
|
spores: meta.spores,
|
||||||
|
codex: [...meta.codex],
|
||||||
|
totalRuns: meta.totalRuns,
|
||||||
|
totalDeaths: meta.totalDeaths,
|
||||||
|
unlockedSchools: [...meta.unlockedSchools],
|
||||||
|
bestRunTime: meta.bestRunTime,
|
||||||
|
bestRunDiscoveries: meta.bestRunDiscoveries,
|
||||||
|
runHistory: [...meta.runHistory],
|
||||||
|
mycelium: meta.mycelium,
|
||||||
|
greatCycle: meta.greatCycle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default empty mycelium graph */
|
||||||
|
const EMPTY_MYCELIUM: MyceliumGraphData = {
|
||||||
|
nodes: [], edges: [], totalDeposits: 0, totalExtractions: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Deserialize from plain object back to MetaState */
|
||||||
|
function deserialize(data: SerializedMetaState): MetaState {
|
||||||
|
return {
|
||||||
|
spores: data.spores ?? 0,
|
||||||
|
codex: data.codex ?? [],
|
||||||
|
totalRuns: data.totalRuns ?? 0,
|
||||||
|
totalDeaths: data.totalDeaths ?? 0,
|
||||||
|
unlockedSchools: data.unlockedSchools ?? ['alchemist'],
|
||||||
|
bestRunTime: data.bestRunTime ?? 0,
|
||||||
|
bestRunDiscoveries: data.bestRunDiscoveries ?? 0,
|
||||||
|
runHistory: data.runHistory ?? [],
|
||||||
|
mycelium: data.mycelium ?? EMPTY_MYCELIUM,
|
||||||
|
greatCycle: data.greatCycle ?? createGreatCycleState(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save meta state to IndexedDB */
|
||||||
|
export async function saveMetaState(meta: MetaState): Promise<void> {
|
||||||
|
const db = await openDB();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||||
|
const store = tx.objectStore(STORE_NAME);
|
||||||
|
const request = store.put(serialize(meta), META_KEY);
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
tx.oncomplete = () => db.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load meta state from IndexedDB. Returns fresh state if nothing saved. */
|
||||||
|
export async function loadMetaState(): Promise<MetaState> {
|
||||||
|
try {
|
||||||
|
const db = await openDB();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||||
|
const store = tx.objectStore(STORE_NAME);
|
||||||
|
const request = store.get(META_KEY);
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const data = request.result as SerializedMetaState | undefined;
|
||||||
|
resolve(data ? deserialize(data) : createMetaState());
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
tx.oncomplete = () => db.close();
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// If IndexedDB is unavailable, return fresh state
|
||||||
|
return createMetaState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear all saved data (for debug/reset) */
|
||||||
|
export async function clearMetaState(): Promise<void> {
|
||||||
|
const db = await openDB();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||||
|
const store = tx.objectStore(STORE_NAME);
|
||||||
|
const request = store.delete(META_KEY);
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
tx.oncomplete = () => db.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
101
src/run/state.ts
Normal file
101
src/run/state.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* Run State Management — creates and updates the state of a single run.
|
||||||
|
*
|
||||||
|
* A "run" is one cycle: Awakening → Exploration → Escalation → Crisis → Resolution.
|
||||||
|
* Each run tracks discoveries, escalation level, and crisis status.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type RunState,
|
||||||
|
type RunDiscoveries,
|
||||||
|
RunPhase,
|
||||||
|
ESCALATION_RATE,
|
||||||
|
SPORE_REWARDS,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
/** Create a fresh run state for a new run */
|
||||||
|
export function createRunState(
|
||||||
|
runId: number,
|
||||||
|
schoolId: string,
|
||||||
|
biomeId: string = 'catalytic-wastes',
|
||||||
|
worldSeed: number = Date.now(),
|
||||||
|
): RunState {
|
||||||
|
return {
|
||||||
|
runId,
|
||||||
|
schoolId,
|
||||||
|
biomeId,
|
||||||
|
phase: RunPhase.Awakening,
|
||||||
|
phaseTimer: 0,
|
||||||
|
elapsed: 0,
|
||||||
|
escalation: 0,
|
||||||
|
crisisActive: false,
|
||||||
|
crisisResolved: false,
|
||||||
|
discoveries: {
|
||||||
|
elements: new Set<string>(),
|
||||||
|
reactions: new Set<string>(),
|
||||||
|
compounds: new Set<string>(),
|
||||||
|
creatures: new Set<string>(),
|
||||||
|
},
|
||||||
|
alive: true,
|
||||||
|
worldSeed,
|
||||||
|
deathPosition: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Advance to the next phase. Does nothing if already at Resolution. */
|
||||||
|
export function advancePhase(state: RunState): void {
|
||||||
|
if (state.phase < RunPhase.Resolution) {
|
||||||
|
state.phase = (state.phase + 1) as RunPhase;
|
||||||
|
state.phaseTimer = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update escalation level based on elapsed delta (ms). Only active during Escalation and Crisis. */
|
||||||
|
export function updateEscalation(state: RunState, deltaMs: number): void {
|
||||||
|
if (state.phase < RunPhase.Escalation) return;
|
||||||
|
if (state.phase > RunPhase.Crisis) return;
|
||||||
|
|
||||||
|
const deltaSec = deltaMs / 1000;
|
||||||
|
state.escalation = Math.min(1.0, state.escalation + ESCALATION_RATE * deltaSec);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Discovery type names used in API (singular) */
|
||||||
|
export type DiscoveryType = 'element' | 'reaction' | 'compound' | 'creature';
|
||||||
|
|
||||||
|
/** Map singular type to plural key in RunDiscoveries */
|
||||||
|
const DISCOVERY_KEY: Record<DiscoveryType, keyof RunDiscoveries> = {
|
||||||
|
element: 'elements',
|
||||||
|
reaction: 'reactions',
|
||||||
|
compound: 'compounds',
|
||||||
|
creature: 'creatures',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Record a discovery during the current run */
|
||||||
|
export function recordDiscovery(
|
||||||
|
state: RunState,
|
||||||
|
type: DiscoveryType,
|
||||||
|
id: string,
|
||||||
|
): void {
|
||||||
|
state.discoveries[DISCOVERY_KEY[type]].add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Count total unique discoveries in a run */
|
||||||
|
export function countDiscoveries(state: RunState): number {
|
||||||
|
return (
|
||||||
|
state.discoveries.elements.size +
|
||||||
|
state.discoveries.reactions.size +
|
||||||
|
state.discoveries.compounds.size +
|
||||||
|
state.discoveries.creatures.size
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calculate spores earned from a run */
|
||||||
|
export function calculateSpores(state: RunState): number {
|
||||||
|
let total = 0;
|
||||||
|
total += state.discoveries.elements.size * SPORE_REWARDS.element;
|
||||||
|
total += state.discoveries.reactions.size * SPORE_REWARDS.reaction;
|
||||||
|
total += state.discoveries.compounds.size * SPORE_REWARDS.compound;
|
||||||
|
total += state.discoveries.creatures.size * SPORE_REWARDS.creature;
|
||||||
|
if (state.crisisResolved) total += SPORE_REWARDS.crisisResolved;
|
||||||
|
return total;
|
||||||
|
}
|
||||||
370
src/run/types.ts
Normal file
370
src/run/types.ts
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
/**
|
||||||
|
* Run Cycle Types — schools, run phases, meta-progression
|
||||||
|
*
|
||||||
|
* Defines the structure of a single run (birth → death → rebirth)
|
||||||
|
* and the persistent meta-progression between runs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── Schools ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SchoolData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
nameRu: string;
|
||||||
|
description: string;
|
||||||
|
descriptionRu: string;
|
||||||
|
/** Starting element symbols (looked up in element registry) */
|
||||||
|
startingElements: string[];
|
||||||
|
/** Quantity of each starting element */
|
||||||
|
startingQuantities: Record<string, number>;
|
||||||
|
/** Scientific principle the school teaches */
|
||||||
|
principle: string;
|
||||||
|
principleRu: string;
|
||||||
|
/** Play style description */
|
||||||
|
playstyle: string;
|
||||||
|
playstyleRu: string;
|
||||||
|
/** Hex color for UI representation */
|
||||||
|
color: string;
|
||||||
|
/** Passive gameplay bonuses from this school's principle */
|
||||||
|
bonuses: Record<string, number>;
|
||||||
|
/** Condition to unlock this school (absent = always unlocked) */
|
||||||
|
unlockCondition?: SchoolUnlockCondition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Condition that must be met to unlock a school */
|
||||||
|
export interface SchoolUnlockCondition {
|
||||||
|
/** What metric to check */
|
||||||
|
type: 'elements_discovered' | 'creatures_discovered' | 'runs_completed';
|
||||||
|
/** How many needed */
|
||||||
|
threshold: number;
|
||||||
|
/** Hint text (English) shown when locked */
|
||||||
|
hint: string;
|
||||||
|
/** Hint text (Russian) shown when locked */
|
||||||
|
hintRu: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolved school bonuses with all multipliers filled in (defaults = 1.0) */
|
||||||
|
export interface ResolvedSchoolBonuses {
|
||||||
|
/** Projectile damage multiplier */
|
||||||
|
projectileDamage: number;
|
||||||
|
/** Player movement speed multiplier */
|
||||||
|
movementSpeed: number;
|
||||||
|
/** Creature aggro/flee range multiplier (< 1 = less aggro) */
|
||||||
|
creatureAggroRange: number;
|
||||||
|
/** Reaction efficiency multiplier */
|
||||||
|
reactionEfficiency: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default bonuses (no school effect) */
|
||||||
|
export const DEFAULT_SCHOOL_BONUSES: ResolvedSchoolBonuses = {
|
||||||
|
projectileDamage: 1.0,
|
||||||
|
movementSpeed: 1.0,
|
||||||
|
creatureAggroRange: 1.0,
|
||||||
|
reactionEfficiency: 1.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Run Phases ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Phases of a single run in order */
|
||||||
|
export enum RunPhase {
|
||||||
|
Awakening = 0,
|
||||||
|
Exploration = 1,
|
||||||
|
Escalation = 2,
|
||||||
|
Crisis = 3,
|
||||||
|
Resolution = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable names for RunPhase */
|
||||||
|
export const RUN_PHASE_NAMES: Record<RunPhase, string> = {
|
||||||
|
[RunPhase.Awakening]: 'Awakening',
|
||||||
|
[RunPhase.Exploration]: 'Exploration',
|
||||||
|
[RunPhase.Escalation]: 'Escalation',
|
||||||
|
[RunPhase.Crisis]: 'Crisis',
|
||||||
|
[RunPhase.Resolution]: 'Resolution',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RUN_PHASE_NAMES_RU: Record<RunPhase, string> = {
|
||||||
|
[RunPhase.Awakening]: 'Пробуждение',
|
||||||
|
[RunPhase.Exploration]: 'Исследование',
|
||||||
|
[RunPhase.Escalation]: 'Эскалация',
|
||||||
|
[RunPhase.Crisis]: 'Кризис',
|
||||||
|
[RunPhase.Resolution]: 'Развязка',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Crisis Types ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type CrisisType = 'chemical-plague';
|
||||||
|
|
||||||
|
export interface CrisisConfig {
|
||||||
|
type: CrisisType;
|
||||||
|
name: string;
|
||||||
|
nameRu: string;
|
||||||
|
description: string;
|
||||||
|
descriptionRu: string;
|
||||||
|
/** Escalation threshold (0-1) at which crisis triggers */
|
||||||
|
triggerThreshold: number;
|
||||||
|
/** Compound that neutralizes the crisis */
|
||||||
|
neutralizer: string;
|
||||||
|
/** Amount needed to neutralize */
|
||||||
|
neutralizeAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Run State ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** State of the current run */
|
||||||
|
export interface RunState {
|
||||||
|
/** Unique run identifier */
|
||||||
|
runId: number;
|
||||||
|
/** Selected school id */
|
||||||
|
schoolId: string;
|
||||||
|
/** Biome being explored this run */
|
||||||
|
biomeId: string;
|
||||||
|
/** Current phase */
|
||||||
|
phase: RunPhase;
|
||||||
|
/** Time spent in current phase (ms) */
|
||||||
|
phaseTimer: number;
|
||||||
|
/** Total run elapsed time (ms) */
|
||||||
|
elapsed: number;
|
||||||
|
/** Escalation level 0.0 – 1.0 */
|
||||||
|
escalation: number;
|
||||||
|
/** Whether crisis has been triggered */
|
||||||
|
crisisActive: boolean;
|
||||||
|
/** Whether crisis has been resolved */
|
||||||
|
crisisResolved: boolean;
|
||||||
|
/** Discoveries made this run */
|
||||||
|
discoveries: RunDiscoveries;
|
||||||
|
/** Whether the player is alive */
|
||||||
|
alive: boolean;
|
||||||
|
/** World seed for this run */
|
||||||
|
worldSeed: number;
|
||||||
|
/** Player death position in tile coords (set on death) */
|
||||||
|
deathPosition: { tileX: number; tileY: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunDiscoveries {
|
||||||
|
elements: Set<string>;
|
||||||
|
reactions: Set<string>;
|
||||||
|
compounds: Set<string>;
|
||||||
|
creatures: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Meta-Progression (persists between runs) ────────────────────
|
||||||
|
|
||||||
|
export interface CodexEntry {
|
||||||
|
id: string;
|
||||||
|
type: 'element' | 'reaction' | 'compound' | 'creature' | 'boss';
|
||||||
|
discoveredOnRun: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetaState {
|
||||||
|
/** Total spores earned across all runs */
|
||||||
|
spores: number;
|
||||||
|
/** All codex entries (permanent knowledge) */
|
||||||
|
codex: CodexEntry[];
|
||||||
|
/** Total runs completed */
|
||||||
|
totalRuns: number;
|
||||||
|
/** Total deaths */
|
||||||
|
totalDeaths: number;
|
||||||
|
/** Unlocked school ids */
|
||||||
|
unlockedSchools: string[];
|
||||||
|
/** Best run statistics */
|
||||||
|
bestRunTime: number;
|
||||||
|
bestRunDiscoveries: number;
|
||||||
|
/** Run history summaries */
|
||||||
|
runHistory: RunSummary[];
|
||||||
|
/** Mycelium knowledge graph (persistent between runs) */
|
||||||
|
mycelium: MyceliumGraphData;
|
||||||
|
/** Great cycle state (7-run macro cycles) */
|
||||||
|
greatCycle: GreatCycleState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serializable Mycelium graph data (stored in MetaState) */
|
||||||
|
export interface MyceliumGraphData {
|
||||||
|
/** Knowledge nodes */
|
||||||
|
nodes: MyceliumNodeData[];
|
||||||
|
/** Connections between nodes */
|
||||||
|
edges: MyceliumEdgeData[];
|
||||||
|
/** Total deposits across all runs */
|
||||||
|
totalDeposits: number;
|
||||||
|
/** Total extractions across all runs */
|
||||||
|
totalExtractions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A knowledge node in the persistent Mycelium graph */
|
||||||
|
export interface MyceliumNodeData {
|
||||||
|
/** Unique node ID (format: "type:id", e.g. "element:Na") */
|
||||||
|
id: string;
|
||||||
|
/** Knowledge category */
|
||||||
|
type: 'element' | 'reaction' | 'compound' | 'creature';
|
||||||
|
/** Reference to the specific knowledge item */
|
||||||
|
knowledgeId: string;
|
||||||
|
/** Run on which this was first deposited */
|
||||||
|
depositedOnRun: number;
|
||||||
|
/** Knowledge strength 0–1 (increases with repeated deposits) */
|
||||||
|
strength: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An edge connecting two knowledge nodes in the Mycelium */
|
||||||
|
export interface MyceliumEdgeData {
|
||||||
|
/** Source node ID */
|
||||||
|
from: string;
|
||||||
|
/** Target node ID */
|
||||||
|
to: string;
|
||||||
|
/** Connection strength 0–1 */
|
||||||
|
weight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunSummary {
|
||||||
|
runId: number;
|
||||||
|
schoolId: string;
|
||||||
|
duration: number;
|
||||||
|
phase: RunPhase;
|
||||||
|
discoveries: number;
|
||||||
|
sporesEarned: number;
|
||||||
|
crisisResolved: boolean;
|
||||||
|
/** Biome explored (added Phase 11) */
|
||||||
|
biomeId?: string;
|
||||||
|
/** Great cycle number when this run occurred */
|
||||||
|
cycleNumber?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Body Composition (for death animation) ──────────────────────
|
||||||
|
|
||||||
|
/** Real elemental composition of the human body (simplified) */
|
||||||
|
export const BODY_COMPOSITION: { symbol: string; fraction: number }[] = [
|
||||||
|
{ symbol: 'O', fraction: 0.65 },
|
||||||
|
{ symbol: 'C', fraction: 0.18 },
|
||||||
|
{ symbol: 'H', fraction: 0.10 },
|
||||||
|
{ symbol: 'N', fraction: 0.03 },
|
||||||
|
{ symbol: 'Ca', fraction: 0.015 },
|
||||||
|
{ symbol: 'P', fraction: 0.01 },
|
||||||
|
{ symbol: 'S', fraction: 0.003 },
|
||||||
|
{ symbol: 'Na', fraction: 0.002 },
|
||||||
|
{ symbol: 'K', fraction: 0.002 },
|
||||||
|
{ symbol: 'Fe', fraction: 0.001 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Great Cycle (every 7 runs) ──────────────────────────────────
|
||||||
|
|
||||||
|
/** Narrative themes for each great cycle (GDD Section IV) */
|
||||||
|
export type CycleTheme =
|
||||||
|
| 'awakening' // Cycle 1 (runs 1-7): learning the world
|
||||||
|
| 'doubt' // Cycle 2 (runs 8-14): finding traces of past adepts
|
||||||
|
| 'realization' // Cycle 3 (runs 15-21): understanding the nature of cycles
|
||||||
|
| 'attempt' // Cycle 4 (runs 22-28): first attempts to transcend
|
||||||
|
| 'acceptance' // Cycle 5 (runs 29-35): cycle is not a prison
|
||||||
|
| 'synthesis'; // Cycle 6+ (runs 36+): unifying all knowledge
|
||||||
|
|
||||||
|
/** Human-readable names for CycleTheme */
|
||||||
|
export const CYCLE_THEME_NAMES: Record<CycleTheme, string> = {
|
||||||
|
awakening: 'Awakening',
|
||||||
|
doubt: 'Doubt',
|
||||||
|
realization: 'Realization',
|
||||||
|
attempt: 'Attempt',
|
||||||
|
acceptance: 'Acceptance',
|
||||||
|
synthesis: 'Synthesis',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CYCLE_THEME_NAMES_RU: Record<CycleTheme, string> = {
|
||||||
|
awakening: 'Пробуждение',
|
||||||
|
doubt: 'Сомнение',
|
||||||
|
realization: 'Осознание',
|
||||||
|
attempt: 'Попытка',
|
||||||
|
acceptance: 'Принятие',
|
||||||
|
synthesis: 'Синтез',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Ordered themes by cycle number (0-indexed, cycles beyond 6 repeat 'synthesis') */
|
||||||
|
export const CYCLE_THEMES: CycleTheme[] = [
|
||||||
|
'awakening',
|
||||||
|
'doubt',
|
||||||
|
'realization',
|
||||||
|
'attempt',
|
||||||
|
'acceptance',
|
||||||
|
'synthesis',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** A trace left by a completed run within a great cycle */
|
||||||
|
export interface RunTrace {
|
||||||
|
/** Run ID this trace belongs to */
|
||||||
|
runId: number;
|
||||||
|
/** Position within the great cycle (1-7) */
|
||||||
|
runInCycle: number;
|
||||||
|
/** School used this run */
|
||||||
|
schoolId: string;
|
||||||
|
/** Biome explored */
|
||||||
|
biomeId: string;
|
||||||
|
/** Death location (tile coords), null if run completed via boss victory */
|
||||||
|
deathPosition: { tileX: number; tileY: number } | null;
|
||||||
|
/** Phase reached when run ended */
|
||||||
|
phaseReached: RunPhase;
|
||||||
|
/** Was the crisis resolved? */
|
||||||
|
crisisResolved: boolean;
|
||||||
|
/** Number of unique discoveries */
|
||||||
|
discoveryCount: number;
|
||||||
|
/** Key element discoveries (up to 5 symbols for trace markers) */
|
||||||
|
keyElements: string[];
|
||||||
|
/** Duration of the run in ms */
|
||||||
|
duration: number;
|
||||||
|
/** World seed used for this run */
|
||||||
|
worldSeed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persistent great cycle state (stored in MetaState) */
|
||||||
|
export interface GreatCycleState {
|
||||||
|
/** Current great cycle number (1-based) */
|
||||||
|
cycleNumber: number;
|
||||||
|
/** Current run within the cycle (1-7, resets on renewal) */
|
||||||
|
runInCycle: number;
|
||||||
|
/** Current cycle's theme */
|
||||||
|
theme: CycleTheme;
|
||||||
|
/** Traces from runs in the CURRENT cycle (cleared on renewal) */
|
||||||
|
currentCycleTraces: RunTrace[];
|
||||||
|
/** Traces from the PREVIOUS cycle (for cross-cycle references) */
|
||||||
|
previousCycleTraces: RunTrace[];
|
||||||
|
/** Total great renewals completed */
|
||||||
|
renewalsCompleted: number;
|
||||||
|
/** Mycelium maturation level (increases each renewal) */
|
||||||
|
myceliumMaturation: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Number of runs per great cycle */
|
||||||
|
export const RUNS_PER_CYCLE = 7;
|
||||||
|
|
||||||
|
/** World generation modifiers based on cycle number */
|
||||||
|
export interface CycleWorldModifiers {
|
||||||
|
/** Elevation noise scale multiplier (world shape variation) */
|
||||||
|
elevationScaleMultiplier: number;
|
||||||
|
/** Detail noise scale multiplier */
|
||||||
|
detailScaleMultiplier: number;
|
||||||
|
/** Resource density multiplier */
|
||||||
|
resourceDensityMultiplier: number;
|
||||||
|
/** Creature spawn rate multiplier */
|
||||||
|
creatureSpawnMultiplier: number;
|
||||||
|
/** Escalation rate multiplier (difficulty) */
|
||||||
|
escalationRateMultiplier: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Constants ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Phase durations in ms (approximate, can be adjusted) */
|
||||||
|
export const PHASE_DURATIONS: Record<RunPhase, number> = {
|
||||||
|
[RunPhase.Awakening]: 0, // ends when player leaves Cradle
|
||||||
|
[RunPhase.Exploration]: 180_000, // 3 minutes
|
||||||
|
[RunPhase.Escalation]: 120_000, // 2 minutes
|
||||||
|
[RunPhase.Crisis]: 0, // ends when resolved or player dies
|
||||||
|
[RunPhase.Resolution]: 60_000, // 1 minute
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Escalation growth rate per second (0→1 over Escalation phase) */
|
||||||
|
export const ESCALATION_RATE = 0.005;
|
||||||
|
|
||||||
|
/** Spores awarded per discovery type */
|
||||||
|
export const SPORE_REWARDS = {
|
||||||
|
element: 5,
|
||||||
|
reaction: 10,
|
||||||
|
compound: 8,
|
||||||
|
creature: 15,
|
||||||
|
crisisResolved: 50,
|
||||||
|
runCompleted: 20,
|
||||||
|
};
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
|
import { loadMetaState } from '../run/persistence';
|
||||||
|
import { createMetaState } from '../run/meta';
|
||||||
|
|
||||||
export class BootScene extends Phaser.Scene {
|
export class BootScene extends Phaser.Scene {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -30,7 +32,7 @@ export class BootScene extends Phaser.Scene {
|
|||||||
|
|
||||||
// Version
|
// Version
|
||||||
this.add
|
this.add
|
||||||
.text(cx, cy + 80, 'v0.2.0 — Phase 2: ECS Foundation', {
|
.text(cx, cy + 80, 'v0.6.0 — Phase 6: Run Cycle', {
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
color: '#333333',
|
color: '#333333',
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
@@ -56,7 +58,15 @@ export class BootScene extends Phaser.Scene {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.input.once('pointerdown', () => {
|
this.input.once('pointerdown', () => {
|
||||||
this.scene.start('GameScene');
|
// Load meta-progression from IndexedDB, then go to Cradle
|
||||||
|
loadMetaState()
|
||||||
|
.then(meta => {
|
||||||
|
this.scene.start('CradleScene', { meta });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Fallback to fresh meta if persistence fails
|
||||||
|
this.scene.start('CradleScene', { meta: createMetaState() });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
772
src/scenes/BossArenaScene.ts
Normal file
772
src/scenes/BossArenaScene.ts
Normal file
@@ -0,0 +1,772 @@
|
|||||||
|
/**
|
||||||
|
* BossArenaScene — Archon boss fight in a circular arena
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Circular arena tilemap with hazards
|
||||||
|
* - Boss entity with cyclical phase AI
|
||||||
|
* - 3 victory paths (chemical, direct, catalytic)
|
||||||
|
* - Boss health bar and phase indicator
|
||||||
|
* - Reward on victory → transition to CradleScene
|
||||||
|
* - Death → normal DeathScene flow
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Phaser from 'phaser';
|
||||||
|
import { createGameWorld, updateTime, type GameWorld } from '../ecs/world';
|
||||||
|
import { Health, Position, Boss as BossComponent, Velocity } from '../ecs/components';
|
||||||
|
import { movementSystem } from '../ecs/systems/movement';
|
||||||
|
import { healthSystem } from '../ecs/systems/health';
|
||||||
|
import { removeGameEntity } from '../ecs/factory';
|
||||||
|
import { PhaserBridge } from '../ecs/bridge';
|
||||||
|
import biomeDataArray from '../data/biomes.json';
|
||||||
|
import bossDataArray from '../data/bosses.json';
|
||||||
|
import type { BiomeData } from '../world/types';
|
||||||
|
import { createWorldTilemap } from '../world/tilemap';
|
||||||
|
import { buildWalkableSet } from '../player/collision';
|
||||||
|
import { createPlayerEntity } from '../player/factory';
|
||||||
|
import { Inventory } from '../player/inventory';
|
||||||
|
import {
|
||||||
|
launchProjectile,
|
||||||
|
projectileSystem,
|
||||||
|
type ProjectileData,
|
||||||
|
} from '../player/projectile';
|
||||||
|
import { playerInputSystem } from '../player/input';
|
||||||
|
import { tileCollisionSystem } from '../player/collision';
|
||||||
|
import { QuickSlots } from '../player/quickslots';
|
||||||
|
import type { InputState } from '../player/types';
|
||||||
|
|
||||||
|
// Boss imports
|
||||||
|
import type { BossData, BossState, BossPhaseEvent } from '../boss/types';
|
||||||
|
import { BossPhase, BOSS_PHASE_NAMES_RU } from '../boss/types';
|
||||||
|
import { createBossState, updateBossPhase, getEffectiveArmor, isVulnerable } from '../boss/ai';
|
||||||
|
import { applyBossDamage, isBossDefeated } from '../boss/victory';
|
||||||
|
import { generateArena, buildArenaWalkableSet } from '../boss/arena';
|
||||||
|
import { createBossEntity } from '../boss/factory';
|
||||||
|
import { calculateBossReward, applyBossReward } from '../boss/reward';
|
||||||
|
|
||||||
|
// Run cycle imports
|
||||||
|
import type { MetaState, RunState } from '../run/types';
|
||||||
|
import { query } from 'bitecs';
|
||||||
|
import { Projectile } from '../ecs/components';
|
||||||
|
|
||||||
|
// UI zoom compensation
|
||||||
|
import { fixToScreen } from '../ui/screen-fix';
|
||||||
|
|
||||||
|
/** Data passed from GameScene to BossArenaScene */
|
||||||
|
interface BossArenaInitData {
|
||||||
|
meta: MetaState;
|
||||||
|
runState: RunState;
|
||||||
|
inventoryItems: { id: string; count: number }[];
|
||||||
|
quickSlotItems: (string | null)[];
|
||||||
|
activeSlot: number;
|
||||||
|
playerHealth: number;
|
||||||
|
playerMaxHealth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BossArenaScene extends Phaser.Scene {
|
||||||
|
private gameWorld!: GameWorld;
|
||||||
|
private bridge!: PhaserBridge;
|
||||||
|
|
||||||
|
// Player
|
||||||
|
private playerEid!: number;
|
||||||
|
private inventory!: Inventory;
|
||||||
|
private quickSlots!: QuickSlots;
|
||||||
|
private walkableSet!: Set<number>;
|
||||||
|
private worldGrid!: number[][];
|
||||||
|
private tileSize!: number;
|
||||||
|
private projectileData!: Map<number, ProjectileData>;
|
||||||
|
private keys!: {
|
||||||
|
W: Phaser.Input.Keyboard.Key;
|
||||||
|
A: Phaser.Input.Keyboard.Key;
|
||||||
|
S: Phaser.Input.Keyboard.Key;
|
||||||
|
D: Phaser.Input.Keyboard.Key;
|
||||||
|
E: Phaser.Input.Keyboard.Key;
|
||||||
|
F: Phaser.Input.Keyboard.Key;
|
||||||
|
ONE: Phaser.Input.Keyboard.Key;
|
||||||
|
TWO: Phaser.Input.Keyboard.Key;
|
||||||
|
THREE: Phaser.Input.Keyboard.Key;
|
||||||
|
FOUR: Phaser.Input.Keyboard.Key;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Boss
|
||||||
|
private bossEid!: number;
|
||||||
|
private bossData!: BossData;
|
||||||
|
private bossState!: BossState;
|
||||||
|
private bossProjectiles: number[] = [];
|
||||||
|
private bossAttackTimer = 0;
|
||||||
|
|
||||||
|
// Run state
|
||||||
|
private meta!: MetaState;
|
||||||
|
private runState!: RunState;
|
||||||
|
private playerDead = false;
|
||||||
|
private victoryAchieved = false;
|
||||||
|
|
||||||
|
// UI elements
|
||||||
|
private bossHealthBar!: Phaser.GameObjects.Graphics;
|
||||||
|
private bossHealthText!: Phaser.GameObjects.Text;
|
||||||
|
private phaseText!: Phaser.GameObjects.Text;
|
||||||
|
private feedbackText!: Phaser.GameObjects.Text;
|
||||||
|
private feedbackTimer = 0;
|
||||||
|
private bossGlowGraphics!: Phaser.GameObjects.Graphics;
|
||||||
|
|
||||||
|
// Input debounce
|
||||||
|
private wasFDown = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super({ key: 'BossArenaScene' });
|
||||||
|
}
|
||||||
|
|
||||||
|
init(data: BossArenaInitData): void {
|
||||||
|
this.meta = data.meta;
|
||||||
|
this.runState = data.runState;
|
||||||
|
this.playerDead = false;
|
||||||
|
this.victoryAchieved = false;
|
||||||
|
this.bossProjectiles = [];
|
||||||
|
this.bossAttackTimer = 0;
|
||||||
|
|
||||||
|
// Restore inventory
|
||||||
|
this.inventory = new Inventory(500, 20);
|
||||||
|
for (const item of data.inventoryItems) {
|
||||||
|
for (let i = 0; i < item.count; i++) {
|
||||||
|
this.inventory.addItem(item.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore quick slots
|
||||||
|
this.quickSlots = new QuickSlots();
|
||||||
|
for (let i = 0; i < data.quickSlotItems.length; i++) {
|
||||||
|
this.quickSlots.assign(i, data.quickSlotItems[i]);
|
||||||
|
}
|
||||||
|
this.quickSlots.setActive(data.activeSlot);
|
||||||
|
}
|
||||||
|
|
||||||
|
create(): void {
|
||||||
|
const biome = biomeDataArray[0] as BiomeData;
|
||||||
|
this.bossData = bossDataArray[0] as BossData;
|
||||||
|
this.tileSize = biome.tileSize;
|
||||||
|
|
||||||
|
// 1. Initialize ECS
|
||||||
|
this.gameWorld = createGameWorld();
|
||||||
|
this.bridge = new PhaserBridge(this);
|
||||||
|
this.projectileData = new Map();
|
||||||
|
|
||||||
|
// 2. Generate arena
|
||||||
|
const arena = generateArena(this.bossData, biome);
|
||||||
|
this.worldGrid = arena.grid;
|
||||||
|
this.walkableSet = buildArenaWalkableSet();
|
||||||
|
|
||||||
|
// 3. Create tilemap (reuse world tilemap system)
|
||||||
|
const arenaWorldData = {
|
||||||
|
grid: arena.grid,
|
||||||
|
biome: biome,
|
||||||
|
seed: 0,
|
||||||
|
};
|
||||||
|
createWorldTilemap(this, arenaWorldData);
|
||||||
|
|
||||||
|
// 4. Create player entity at arena entrance
|
||||||
|
this.playerEid = createPlayerEntity(
|
||||||
|
this.gameWorld.world, arena.playerSpawnX, arena.playerSpawnY,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Apply player health from GameScene
|
||||||
|
const initData = this.scene.settings.data as BossArenaInitData;
|
||||||
|
if (initData?.playerHealth !== undefined) {
|
||||||
|
Health.current[this.playerEid] = initData.playerHealth;
|
||||||
|
Health.max[this.playerEid] = initData.playerMaxHealth;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Create boss entity at center
|
||||||
|
this.bossEid = createBossEntity(
|
||||||
|
this.gameWorld.world, this.bossData, arena.bossSpawnX, arena.bossSpawnY,
|
||||||
|
);
|
||||||
|
this.bossState = createBossState(this.bossData);
|
||||||
|
|
||||||
|
// 6. Camera setup
|
||||||
|
const worldPixelW = arena.width * this.tileSize;
|
||||||
|
const worldPixelH = arena.height * this.tileSize;
|
||||||
|
this.cameras.main.setBounds(0, 0, worldPixelW, worldPixelH);
|
||||||
|
this.cameras.main.setZoom(2.0); // Closer zoom for arena
|
||||||
|
|
||||||
|
// Sync bridge to create sprites, then follow player
|
||||||
|
this.bridge.sync(this.gameWorld.world);
|
||||||
|
const playerSprite = this.bridge.getSprite(this.playerEid);
|
||||||
|
if (playerSprite) {
|
||||||
|
playerSprite.setDepth(10);
|
||||||
|
this.cameras.main.startFollow(playerSprite, true, 0.1, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Keyboard input
|
||||||
|
const keyboard = this.input.keyboard;
|
||||||
|
if (!keyboard) throw new Error('Keyboard plugin not available');
|
||||||
|
this.keys = {
|
||||||
|
W: keyboard.addKey('W'),
|
||||||
|
A: keyboard.addKey('A'),
|
||||||
|
S: keyboard.addKey('S'),
|
||||||
|
D: keyboard.addKey('D'),
|
||||||
|
E: keyboard.addKey('E'),
|
||||||
|
F: keyboard.addKey('F'),
|
||||||
|
ONE: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ONE),
|
||||||
|
TWO: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.TWO),
|
||||||
|
THREE: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.THREE),
|
||||||
|
FOUR: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.FOUR),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 8. Boss glow graphics (world-space)
|
||||||
|
this.bossGlowGraphics = this.add.graphics();
|
||||||
|
this.bossGlowGraphics.setDepth(5);
|
||||||
|
|
||||||
|
// 9. UI elements (screen-space)
|
||||||
|
this.createBossUI();
|
||||||
|
|
||||||
|
// 10. Launch UIScene for player HUD
|
||||||
|
this.scene.launch('UIScene');
|
||||||
|
|
||||||
|
// Entry announcement
|
||||||
|
this.showFeedback('⚔ УРОБОРОС — Архонт Циклов');
|
||||||
|
}
|
||||||
|
|
||||||
|
update(_time: number, delta: number): void {
|
||||||
|
if (this.playerDead || this.victoryAchieved) return;
|
||||||
|
|
||||||
|
// 1. Update world time
|
||||||
|
updateTime(this.gameWorld, delta);
|
||||||
|
|
||||||
|
// 2. Player input
|
||||||
|
const input: InputState = {
|
||||||
|
moveX: (this.keys.D.isDown ? 1 : 0) - (this.keys.A.isDown ? 1 : 0),
|
||||||
|
moveY: (this.keys.S.isDown ? 1 : 0) - (this.keys.W.isDown ? 1 : 0),
|
||||||
|
interact: this.keys.E.isDown,
|
||||||
|
};
|
||||||
|
playerInputSystem(this.gameWorld.world, input);
|
||||||
|
|
||||||
|
// 3. Movement
|
||||||
|
movementSystem(this.gameWorld.world, delta);
|
||||||
|
|
||||||
|
// 4. Tile collision (player)
|
||||||
|
tileCollisionSystem(
|
||||||
|
this.gameWorld.world, delta,
|
||||||
|
this.worldGrid, this.tileSize, this.walkableSet,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. Projectile system
|
||||||
|
projectileSystem(
|
||||||
|
this.gameWorld.world, delta,
|
||||||
|
this.worldGrid, this.tileSize, this.walkableSet,
|
||||||
|
this.projectileData,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6. Quick slots
|
||||||
|
if (this.keys.ONE.isDown) this.quickSlots.setActive(0);
|
||||||
|
if (this.keys.TWO.isDown) this.quickSlots.setActive(1);
|
||||||
|
if (this.keys.THREE.isDown) this.quickSlots.setActive(2);
|
||||||
|
if (this.keys.FOUR.isDown) this.quickSlots.setActive(3);
|
||||||
|
|
||||||
|
// 7. Throw projectile (F key)
|
||||||
|
const isFDown = this.keys.F.isDown;
|
||||||
|
const justPressedF = isFDown && !this.wasFDown;
|
||||||
|
this.wasFDown = isFDown;
|
||||||
|
if (justPressedF) {
|
||||||
|
this.tryLaunchProjectile();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Boss AI phase update
|
||||||
|
const bossEvents = updateBossPhase(this.bossState, this.bossData, delta);
|
||||||
|
this.handleBossEvents(bossEvents, delta);
|
||||||
|
|
||||||
|
// Sync boss ECS component with runtime state
|
||||||
|
BossComponent.phase[this.bossEid] = this.bossState.currentPhase;
|
||||||
|
BossComponent.cycleCount[this.bossEid] = this.bossState.cycleCount;
|
||||||
|
|
||||||
|
// 9. Boss attack behavior
|
||||||
|
this.updateBossAttacks(delta);
|
||||||
|
|
||||||
|
// 10. Check projectile → boss collision
|
||||||
|
this.checkProjectileBossCollision();
|
||||||
|
|
||||||
|
// 11. Health system
|
||||||
|
const dead = healthSystem(this.gameWorld.world);
|
||||||
|
let playerDied = false;
|
||||||
|
for (const eid of dead) {
|
||||||
|
if (eid === this.playerEid) {
|
||||||
|
playerDied = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (eid === this.bossEid) {
|
||||||
|
continue; // Boss death handled by victory system
|
||||||
|
}
|
||||||
|
removeGameEntity(this.gameWorld.world, eid);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playerDied) {
|
||||||
|
this.onPlayerDeath();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 12. Check victory
|
||||||
|
if (isBossDefeated(this.bossState)) {
|
||||||
|
this.onBossDefeated();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 13. Render sync
|
||||||
|
this.bridge.sync(this.gameWorld.world);
|
||||||
|
|
||||||
|
// 14. Update boss visuals
|
||||||
|
this.updateBossVisuals(delta);
|
||||||
|
|
||||||
|
// 15. Update UI
|
||||||
|
this.updateBossUI();
|
||||||
|
|
||||||
|
// 16. Feedback text fade
|
||||||
|
if (this.feedbackTimer > 0) {
|
||||||
|
this.feedbackTimer -= delta;
|
||||||
|
if (this.feedbackTimer <= 500) {
|
||||||
|
this.feedbackText.setAlpha(this.feedbackTimer / 500);
|
||||||
|
}
|
||||||
|
if (this.feedbackTimer <= 0) {
|
||||||
|
this.feedbackText.setAlpha(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 17. Push state to registry for UIScene
|
||||||
|
this.registry.set('health', Health.current[this.playerEid] ?? 100);
|
||||||
|
this.registry.set('healthMax', Health.max[this.playerEid] ?? 100);
|
||||||
|
this.registry.set('quickSlots', this.quickSlots.getAll());
|
||||||
|
this.registry.set('activeSlot', this.quickSlots.activeIndex);
|
||||||
|
this.registry.set('invWeight', this.inventory.getTotalWeight());
|
||||||
|
this.registry.set('invMaxWeight', this.inventory.maxWeight);
|
||||||
|
this.registry.set('invSlots', this.inventory.slotCount);
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const item of this.inventory.getItems()) {
|
||||||
|
counts.set(item.id, item.count);
|
||||||
|
}
|
||||||
|
this.registry.set('invCounts', counts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Boss Attack Logic ─────────────────────────────────────────
|
||||||
|
|
||||||
|
private handleBossEvents(events: BossPhaseEvent[], _delta: number): void {
|
||||||
|
for (const event of events) {
|
||||||
|
if (event.type === 'phase_change') {
|
||||||
|
const phaseName = BOSS_PHASE_NAMES_RU[event.phase];
|
||||||
|
this.showFeedback(`Фаза: ${phaseName}`);
|
||||||
|
}
|
||||||
|
if (event.type === 'cycle_complete') {
|
||||||
|
this.showFeedback(`Цикл ${event.cycleCount} завершён — Уроборос ускоряется!`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateBossAttacks(delta: number): void {
|
||||||
|
const phase = this.bossState.currentPhase;
|
||||||
|
const bx = Position.x[this.bossEid];
|
||||||
|
const by = Position.y[this.bossEid];
|
||||||
|
const px = Position.x[this.playerEid];
|
||||||
|
const py = Position.y[this.playerEid];
|
||||||
|
|
||||||
|
switch (phase) {
|
||||||
|
case BossPhase.Coil: {
|
||||||
|
// Boss slowly moves toward player
|
||||||
|
const dx = px - bx;
|
||||||
|
const dy = py - by;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (dist > 30) {
|
||||||
|
const speed = 20 + this.bossState.cycleCount * 5;
|
||||||
|
Velocity.vx[this.bossEid] = (dx / dist) * speed;
|
||||||
|
Velocity.vy[this.bossEid] = (dy / dist) * speed;
|
||||||
|
} else {
|
||||||
|
Velocity.vx[this.bossEid] = 0;
|
||||||
|
Velocity.vy[this.bossEid] = 0;
|
||||||
|
// Close-range damage
|
||||||
|
this.bossAttackTimer -= delta;
|
||||||
|
if (this.bossAttackTimer <= 0) {
|
||||||
|
Health.current[this.playerEid] = Math.max(
|
||||||
|
0, (Health.current[this.playerEid] ?? 0) - this.bossData.damage * 0.5,
|
||||||
|
);
|
||||||
|
this.bossAttackTimer = 1500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case BossPhase.Spray: {
|
||||||
|
// Boss stays still, shoots acid projectiles in rotating pattern
|
||||||
|
Velocity.vx[this.bossEid] = 0;
|
||||||
|
Velocity.vy[this.bossEid] = 0;
|
||||||
|
|
||||||
|
this.bossAttackTimer -= delta;
|
||||||
|
if (this.bossAttackTimer <= 0) {
|
||||||
|
const sprayCount = 4 + this.bossState.cycleCount;
|
||||||
|
const baseAngle = (Date.now() / 1000) * 2; // Rotating
|
||||||
|
for (let i = 0; i < sprayCount; i++) {
|
||||||
|
const angle = baseAngle + (i * 2 * Math.PI / sprayCount);
|
||||||
|
const targetX = bx + Math.cos(angle) * 200;
|
||||||
|
const targetY = by + Math.sin(angle) * 200;
|
||||||
|
this.spawnBossProjectile(bx, by, targetX, targetY);
|
||||||
|
}
|
||||||
|
this.bossAttackTimer = 1500 - this.bossState.cycleCount * 150;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case BossPhase.Lash: {
|
||||||
|
// Boss sweeps tail — area damage if player is in range + angle
|
||||||
|
Velocity.vx[this.bossEid] = 0;
|
||||||
|
Velocity.vy[this.bossEid] = 0;
|
||||||
|
|
||||||
|
this.bossAttackTimer -= delta;
|
||||||
|
if (this.bossAttackTimer <= 0) {
|
||||||
|
const dx = px - bx;
|
||||||
|
const dy = py - by;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (dist < 80 + this.bossState.cycleCount * 10) {
|
||||||
|
const damage = this.bossData.damage * (1 + this.bossState.cycleCount * 0.2);
|
||||||
|
Health.current[this.playerEid] = Math.max(
|
||||||
|
0, (Health.current[this.playerEid] ?? 0) - damage,
|
||||||
|
);
|
||||||
|
this.showFeedback(`Удар хвостом! (${Math.round(damage)} урона)`);
|
||||||
|
}
|
||||||
|
this.bossAttackTimer = 2000 - this.bossState.cycleCount * 200;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case BossPhase.Digest: {
|
||||||
|
// Boss is immobile and vulnerable
|
||||||
|
Velocity.vx[this.bossEid] = 0;
|
||||||
|
Velocity.vy[this.bossEid] = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private spawnBossProjectile(fromX: number, fromY: number, toX: number, toY: number): void {
|
||||||
|
launchProjectile(
|
||||||
|
this.gameWorld.world,
|
||||||
|
this.projectileData,
|
||||||
|
fromX, fromY,
|
||||||
|
toX, toY,
|
||||||
|
'__boss_acid__',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Projectile → Boss Collision ───────────────────────────────
|
||||||
|
|
||||||
|
private checkProjectileBossCollision(): void {
|
||||||
|
const projEntities = query(this.gameWorld.world, [Position, Projectile]);
|
||||||
|
const bx = Position.x[this.bossEid];
|
||||||
|
const by = Position.y[this.bossEid];
|
||||||
|
const hitRadiusSq = (this.bossData.radius + 5) * (this.bossData.radius + 5);
|
||||||
|
|
||||||
|
for (const projEid of projEntities) {
|
||||||
|
const projInfo = this.projectileData.get(projEid);
|
||||||
|
if (!projInfo || projInfo.itemId === '__boss_acid__') continue; // Skip boss projectiles
|
||||||
|
|
||||||
|
const dx = Position.x[projEid] - bx;
|
||||||
|
const dy = Position.y[projEid] - by;
|
||||||
|
const distSq = dx * dx + dy * dy;
|
||||||
|
|
||||||
|
if (distSq <= hitRadiusSq) {
|
||||||
|
// Hit the boss!
|
||||||
|
const result = applyBossDamage(this.bossState, this.bossData, projInfo.itemId);
|
||||||
|
|
||||||
|
// Sync health to ECS
|
||||||
|
Health.current[this.bossEid] = this.bossState.health;
|
||||||
|
|
||||||
|
// Remove projectile
|
||||||
|
removeGameEntity(this.gameWorld.world, projEid);
|
||||||
|
this.projectileData.delete(projEid);
|
||||||
|
|
||||||
|
// Show feedback
|
||||||
|
this.showFeedback(result.messageRu);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check boss projectile → player collision
|
||||||
|
const px = Position.x[this.playerEid];
|
||||||
|
const py = Position.y[this.playerEid];
|
||||||
|
const playerHitRadiusSq = 15 * 15;
|
||||||
|
|
||||||
|
for (const projEid of projEntities) {
|
||||||
|
const projInfo = this.projectileData.get(projEid);
|
||||||
|
if (!projInfo || projInfo.itemId !== '__boss_acid__') continue;
|
||||||
|
|
||||||
|
const dx = Position.x[projEid] - px;
|
||||||
|
const dy = Position.y[projEid] - py;
|
||||||
|
const distSq = dx * dx + dy * dy;
|
||||||
|
|
||||||
|
if (distSq <= playerHitRadiusSq) {
|
||||||
|
// Boss projectile hits player
|
||||||
|
const damage = this.bossData.damage * 0.7;
|
||||||
|
Health.current[this.playerEid] = Math.max(
|
||||||
|
0, (Health.current[this.playerEid] ?? 0) - damage,
|
||||||
|
);
|
||||||
|
removeGameEntity(this.gameWorld.world, projEid);
|
||||||
|
this.projectileData.delete(projEid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Boss Visual Effects ───────────────────────────────────────
|
||||||
|
|
||||||
|
private updateBossVisuals(_delta: number): void {
|
||||||
|
const bx = Position.x[this.bossEid];
|
||||||
|
const by = Position.y[this.bossEid];
|
||||||
|
|
||||||
|
this.bossGlowGraphics.clear();
|
||||||
|
|
||||||
|
// Phase-based glow
|
||||||
|
const phase = this.bossState.currentPhase;
|
||||||
|
let glowColor = 0xcc44ff;
|
||||||
|
let glowAlpha = 0.15;
|
||||||
|
let glowRadius = this.bossData.radius * 2;
|
||||||
|
|
||||||
|
switch (phase) {
|
||||||
|
case BossPhase.Coil:
|
||||||
|
glowColor = 0xff4444; // Red — danger
|
||||||
|
glowAlpha = 0.2;
|
||||||
|
break;
|
||||||
|
case BossPhase.Spray:
|
||||||
|
glowColor = 0x88ff00; // Acid green
|
||||||
|
glowAlpha = 0.25;
|
||||||
|
glowRadius = this.bossData.radius * 2.5;
|
||||||
|
break;
|
||||||
|
case BossPhase.Lash:
|
||||||
|
glowColor = 0xffaa00; // Orange — warning
|
||||||
|
glowAlpha = 0.2;
|
||||||
|
break;
|
||||||
|
case BossPhase.Digest:
|
||||||
|
glowColor = 0x4488ff; // Blue — vulnerable
|
||||||
|
glowAlpha = 0.3;
|
||||||
|
glowRadius = this.bossData.radius * 1.5;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pulsing glow
|
||||||
|
const pulse = 0.7 + 0.3 * Math.sin(Date.now() / 300);
|
||||||
|
this.bossGlowGraphics.fillStyle(glowColor, glowAlpha * pulse);
|
||||||
|
this.bossGlowGraphics.fillCircle(bx, by, glowRadius);
|
||||||
|
|
||||||
|
// Inner glow
|
||||||
|
this.bossGlowGraphics.fillStyle(glowColor, glowAlpha * pulse * 1.5);
|
||||||
|
this.bossGlowGraphics.fillCircle(bx, by, glowRadius * 0.5);
|
||||||
|
|
||||||
|
// Catalyst poison visual: darkening stacks
|
||||||
|
if (this.bossState.catalystStacks > 0) {
|
||||||
|
const poisonAlpha = 0.1 * this.bossState.catalystStacks;
|
||||||
|
this.bossGlowGraphics.fillStyle(0x888888, poisonAlpha);
|
||||||
|
this.bossGlowGraphics.fillCircle(bx, by, this.bossData.radius * 1.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── UI ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private createBossUI(): void {
|
||||||
|
const cam = this.cameras.main;
|
||||||
|
|
||||||
|
// Boss health bar background
|
||||||
|
this.bossHealthBar = this.add.graphics();
|
||||||
|
this.bossHealthBar.setScrollFactor(0);
|
||||||
|
this.bossHealthBar.setDepth(100);
|
||||||
|
|
||||||
|
// Boss name + health text
|
||||||
|
this.bossHealthText = this.add.text(cam.width / 2, 20, '', {
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#cc44ff',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
backgroundColor: '#000000cc',
|
||||||
|
padding: { x: 8, y: 4 },
|
||||||
|
});
|
||||||
|
this.bossHealthText.setScrollFactor(0);
|
||||||
|
this.bossHealthText.setOrigin(0.5, 0);
|
||||||
|
this.bossHealthText.setDepth(101);
|
||||||
|
|
||||||
|
// Phase indicator
|
||||||
|
this.phaseText = this.add.text(cam.width / 2, 55, '', {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#ffdd44',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
backgroundColor: '#000000aa',
|
||||||
|
padding: { x: 6, y: 2 },
|
||||||
|
});
|
||||||
|
this.phaseText.setScrollFactor(0);
|
||||||
|
this.phaseText.setOrigin(0.5, 0);
|
||||||
|
this.phaseText.setDepth(101);
|
||||||
|
|
||||||
|
// Feedback text (center)
|
||||||
|
this.feedbackText = this.add.text(cam.width / 2, cam.height - 60, '', {
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#ffdd44',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
backgroundColor: '#000000cc',
|
||||||
|
padding: { x: 8, y: 4 },
|
||||||
|
align: 'center',
|
||||||
|
});
|
||||||
|
this.feedbackText.setScrollFactor(0);
|
||||||
|
this.feedbackText.setOrigin(0.5);
|
||||||
|
this.feedbackText.setDepth(101);
|
||||||
|
this.feedbackText.setAlpha(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateBossUI(): void {
|
||||||
|
const cam = this.cameras.main;
|
||||||
|
const healthPct = this.bossState.health / this.bossState.maxHealth;
|
||||||
|
|
||||||
|
// Fix UI positions for camera zoom.
|
||||||
|
// Graphics at (0,0) — local draw coordinates map 1:1 to screen pixels.
|
||||||
|
fixToScreen(this.bossHealthBar, 0, 0, cam);
|
||||||
|
fixToScreen(this.bossHealthText, cam.width / 2, 20, cam);
|
||||||
|
fixToScreen(this.phaseText, cam.width / 2, 55, cam);
|
||||||
|
fixToScreen(this.feedbackText, cam.width / 2, cam.height - 60, cam);
|
||||||
|
|
||||||
|
// Health bar
|
||||||
|
const barWidth = 300;
|
||||||
|
const barHeight = 8;
|
||||||
|
const barX = (cam.width - barWidth) / 2;
|
||||||
|
const barY = 42;
|
||||||
|
|
||||||
|
this.bossHealthBar.clear();
|
||||||
|
// Background
|
||||||
|
this.bossHealthBar.fillStyle(0x333333, 0.8);
|
||||||
|
this.bossHealthBar.fillRect(barX, barY, barWidth, barHeight);
|
||||||
|
// Fill
|
||||||
|
const fillColor = healthPct > 0.5 ? 0xcc44ff : healthPct > 0.25 ? 0xff8800 : 0xff2222;
|
||||||
|
this.bossHealthBar.fillStyle(fillColor, 1);
|
||||||
|
this.bossHealthBar.fillRect(barX, barY, barWidth * healthPct, barHeight);
|
||||||
|
// Border
|
||||||
|
this.bossHealthBar.lineStyle(1, 0xffffff, 0.3);
|
||||||
|
this.bossHealthBar.strokeRect(barX, barY, barWidth, barHeight);
|
||||||
|
|
||||||
|
// Health text
|
||||||
|
const healthStr = `УРОБОРОС — ${Math.ceil(this.bossState.health)}/${this.bossState.maxHealth}`;
|
||||||
|
this.bossHealthText.setText(healthStr);
|
||||||
|
|
||||||
|
// Phase text
|
||||||
|
const phaseName = BOSS_PHASE_NAMES_RU[this.bossState.currentPhase];
|
||||||
|
const cycleStr = this.bossState.cycleCount > 0 ? ` | Цикл ${this.bossState.cycleCount}` : '';
|
||||||
|
const catalystStr = this.bossState.catalystStacks > 0
|
||||||
|
? ` | ☠ Яд: ${this.bossState.catalystStacks}/${this.bossData.maxCatalystStacks}`
|
||||||
|
: '';
|
||||||
|
const vulnStr = isVulnerable(this.bossState, this.bossData) ? ' | ★ УЯЗВИМ' : '';
|
||||||
|
this.phaseText.setText(`${phaseName}${vulnStr}${cycleStr}${catalystStr}`);
|
||||||
|
|
||||||
|
// Phase color
|
||||||
|
switch (this.bossState.currentPhase) {
|
||||||
|
case BossPhase.Coil:
|
||||||
|
this.phaseText.setColor('#ff4444');
|
||||||
|
break;
|
||||||
|
case BossPhase.Spray:
|
||||||
|
this.phaseText.setColor('#88ff00');
|
||||||
|
break;
|
||||||
|
case BossPhase.Lash:
|
||||||
|
this.phaseText.setColor('#ffaa00');
|
||||||
|
break;
|
||||||
|
case BossPhase.Digest:
|
||||||
|
this.phaseText.setColor('#4488ff');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Victory & Death ───────────────────────────────────────────
|
||||||
|
|
||||||
|
private onBossDefeated(): void {
|
||||||
|
this.victoryAchieved = true;
|
||||||
|
|
||||||
|
// Calculate reward
|
||||||
|
const reward = calculateBossReward(this.bossState, this.bossData);
|
||||||
|
applyBossReward(this.meta, reward, this.runState.runId);
|
||||||
|
|
||||||
|
// Show victory message
|
||||||
|
const cam = this.cameras.main;
|
||||||
|
const victoryText = this.add.text(cam.width / 2, cam.height / 2, '', {
|
||||||
|
fontSize: '20px',
|
||||||
|
color: '#cc44ff',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
backgroundColor: '#000000ee',
|
||||||
|
padding: { x: 16, y: 12 },
|
||||||
|
align: 'center',
|
||||||
|
wordWrap: { width: 500 },
|
||||||
|
});
|
||||||
|
victoryText.setScrollFactor(0);
|
||||||
|
victoryText.setOrigin(0.5);
|
||||||
|
victoryText.setDepth(200);
|
||||||
|
fixToScreen(victoryText, cam.width / 2, cam.height / 2, cam);
|
||||||
|
|
||||||
|
const methodNames: Record<string, string> = {
|
||||||
|
chemical: 'Алхимическая победа (NaOH)',
|
||||||
|
direct: 'Прямая победа',
|
||||||
|
catalytic: 'Каталитическая победа (Hg)',
|
||||||
|
};
|
||||||
|
const method = this.bossState.victoryMethod ?? 'direct';
|
||||||
|
const methodName = methodNames[method] ?? method;
|
||||||
|
|
||||||
|
victoryText.setText(
|
||||||
|
`★ УРОБОРОС ПОВЕРЖЕН ★\n\n${methodName}\n+${reward.spores} спор\n\nАрхонтова Память добавлена в Кодекс`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stop UIScene
|
||||||
|
this.scene.stop('UIScene');
|
||||||
|
|
||||||
|
// Transition after delay
|
||||||
|
this.time.delayedCall(4000, () => {
|
||||||
|
this.cameras.main.fadeOut(1500, 0, 0, 0);
|
||||||
|
this.cameras.main.once('camerafadeoutcomplete', () => {
|
||||||
|
this.scene.start('DeathScene', {
|
||||||
|
meta: this.meta,
|
||||||
|
runState: this.runState,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private onPlayerDeath(): void {
|
||||||
|
this.playerDead = true;
|
||||||
|
this.runState.alive = false;
|
||||||
|
|
||||||
|
this.scene.stop('UIScene');
|
||||||
|
|
||||||
|
this.cameras.main.fadeOut(2000, 0, 0, 0);
|
||||||
|
this.cameras.main.once('camerafadeoutcomplete', () => {
|
||||||
|
this.scene.start('DeathScene', {
|
||||||
|
meta: this.meta,
|
||||||
|
runState: this.runState,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Projectile Launch ─────────────────────────────────────────
|
||||||
|
|
||||||
|
private tryLaunchProjectile(): void {
|
||||||
|
const itemId = this.quickSlots.getActive();
|
||||||
|
if (!itemId || !this.inventory.hasItem(itemId)) return;
|
||||||
|
|
||||||
|
const removed = this.inventory.removeItem(itemId, 1);
|
||||||
|
if (removed === 0) return;
|
||||||
|
|
||||||
|
const pointer = this.input.activePointer;
|
||||||
|
const worldPoint = this.cameras.main.getWorldPoint(pointer.x, pointer.y);
|
||||||
|
|
||||||
|
launchProjectile(
|
||||||
|
this.gameWorld.world,
|
||||||
|
this.projectileData,
|
||||||
|
Position.x[this.playerEid],
|
||||||
|
Position.y[this.playerEid],
|
||||||
|
worldPoint.x,
|
||||||
|
worldPoint.y,
|
||||||
|
itemId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear quick slot if empty
|
||||||
|
if (!this.inventory.hasItem(itemId)) {
|
||||||
|
const slotIdx = this.quickSlots.getAll().indexOf(itemId);
|
||||||
|
if (slotIdx >= 0) this.quickSlots.assign(slotIdx, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Feedback ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private showFeedback(message: string): void {
|
||||||
|
this.feedbackText.setText(message);
|
||||||
|
this.feedbackText.setAlpha(1);
|
||||||
|
this.feedbackTimer = 2500;
|
||||||
|
}
|
||||||
|
}
|
||||||
591
src/scenes/CradleScene.ts
Normal file
591
src/scenes/CradleScene.ts
Normal file
@@ -0,0 +1,591 @@
|
|||||||
|
/**
|
||||||
|
* CradleScene — Spore Cradle (Споровая Колыбель)
|
||||||
|
*
|
||||||
|
* Awakening scene where the player "wakes up" inside a giant mushroom.
|
||||||
|
* Displays school selection and starts the run.
|
||||||
|
* Receives meta state from BootScene or FractalScene.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Phaser from 'phaser';
|
||||||
|
import schoolsData from '../data/schools.json';
|
||||||
|
import biomeDataArray from '../data/biomes.json';
|
||||||
|
import type { SchoolData, MetaState } from '../run/types';
|
||||||
|
import type { BiomeData } from '../world/types';
|
||||||
|
import { isSchoolUnlocked } from '../run/meta';
|
||||||
|
import { GAME_WIDTH, GAME_HEIGHT } from '../config';
|
||||||
|
import { getAvailableBonuses, purchaseBonus, canAffordBonus, resetShopSession } from '../mycelium/shop';
|
||||||
|
import { getGraphStats } from '../mycelium/graph';
|
||||||
|
import { getCycleSummary } from '../run/cycle';
|
||||||
|
import { CYCLE_THEME_NAMES_RU } from '../run/types';
|
||||||
|
import type { BonusEffect } from '../mycelium/types';
|
||||||
|
import narrativeData from '../data/cycle-narrative.json';
|
||||||
|
|
||||||
|
const schools = schoolsData as unknown as SchoolData[];
|
||||||
|
const biomes = biomeDataArray as BiomeData[];
|
||||||
|
|
||||||
|
export class CradleScene extends Phaser.Scene {
|
||||||
|
private meta!: MetaState;
|
||||||
|
private selectedIndex = 0;
|
||||||
|
private selectedBiomeIndex = 0;
|
||||||
|
private schoolCards: Phaser.GameObjects.Container[] = [];
|
||||||
|
private biomeCards: { bg: Phaser.GameObjects.Rectangle; label: Phaser.GameObjects.Text }[] = [];
|
||||||
|
private particles: { x: number; y: number; vx: number; vy: number; alpha: number; radius: number }[] = [];
|
||||||
|
private particleGraphics!: Phaser.GameObjects.Graphics;
|
||||||
|
private introTimer = 0;
|
||||||
|
private introComplete = false;
|
||||||
|
private purchasedEffects: BonusEffect[] = [];
|
||||||
|
private sporeCountText!: Phaser.GameObjects.Text;
|
||||||
|
private shopContainer!: Phaser.GameObjects.Container;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super({ key: 'CradleScene' });
|
||||||
|
}
|
||||||
|
|
||||||
|
init(data: { meta: MetaState }): void {
|
||||||
|
this.meta = data.meta;
|
||||||
|
this.selectedIndex = 0;
|
||||||
|
this.selectedBiomeIndex = 0;
|
||||||
|
this.schoolCards = [];
|
||||||
|
this.biomeCards = [];
|
||||||
|
this.introTimer = 0;
|
||||||
|
this.introComplete = false;
|
||||||
|
this.purchasedEffects = [];
|
||||||
|
resetShopSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
create(): void {
|
||||||
|
const cx = GAME_WIDTH / 2;
|
||||||
|
const cy = GAME_HEIGHT / 2;
|
||||||
|
|
||||||
|
// Background — dark organic interior
|
||||||
|
this.cameras.main.setBackgroundColor('#050808');
|
||||||
|
|
||||||
|
// Ambient spore particles
|
||||||
|
this.particles = [];
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
this.particles.push({
|
||||||
|
x: Math.random() * GAME_WIDTH,
|
||||||
|
y: Math.random() * GAME_HEIGHT,
|
||||||
|
vx: (Math.random() - 0.5) * 15,
|
||||||
|
vy: -Math.random() * 20 - 5,
|
||||||
|
alpha: Math.random() * 0.4 + 0.1,
|
||||||
|
radius: Math.random() * 2 + 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.particleGraphics = this.add.graphics();
|
||||||
|
this.particleGraphics.setDepth(0);
|
||||||
|
|
||||||
|
// Intro text — fades in
|
||||||
|
const introText = this.add.text(cx, cy - 60, '...пробуждение...', {
|
||||||
|
fontSize: '24px',
|
||||||
|
color: '#2a6644',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
});
|
||||||
|
introText.setOrigin(0.5);
|
||||||
|
introText.setAlpha(0);
|
||||||
|
introText.setDepth(10);
|
||||||
|
|
||||||
|
// Fade in intro
|
||||||
|
this.tweens.add({
|
||||||
|
targets: introText,
|
||||||
|
alpha: 1,
|
||||||
|
duration: 2000,
|
||||||
|
ease: 'Sine.easeIn',
|
||||||
|
onComplete: () => {
|
||||||
|
// After intro text appears, fade it out and show school selection
|
||||||
|
this.tweens.add({
|
||||||
|
targets: introText,
|
||||||
|
alpha: 0,
|
||||||
|
duration: 1500,
|
||||||
|
delay: 1000,
|
||||||
|
ease: 'Sine.easeOut',
|
||||||
|
onComplete: () => {
|
||||||
|
introText.destroy();
|
||||||
|
this.showSchoolSelection();
|
||||||
|
this.introComplete = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Meta info (top-right)
|
||||||
|
const graphStats = getGraphStats(this.meta.mycelium);
|
||||||
|
const cycleSummary = getCycleSummary(this.meta.greatCycle);
|
||||||
|
const themeName = CYCLE_THEME_NAMES_RU[cycleSummary.theme];
|
||||||
|
|
||||||
|
const metaInfo = [
|
||||||
|
`Раны: ${this.meta.totalRuns}`,
|
||||||
|
`Кодекс: ${this.meta.codex.length}`,
|
||||||
|
`Мицелий: ${graphStats.nodeCount} узлов`,
|
||||||
|
].join(' | ');
|
||||||
|
this.add.text(GAME_WIDTH - 12, 12, metaInfo, {
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#334433',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
}).setOrigin(1, 0).setDepth(10);
|
||||||
|
|
||||||
|
// Spore count (top-right, below meta info)
|
||||||
|
this.sporeCountText = this.add.text(GAME_WIDTH - 12, 28, `🍄 Споры: ${this.meta.spores}`, {
|
||||||
|
fontSize: '13px',
|
||||||
|
color: '#44ff88',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
});
|
||||||
|
this.sporeCountText.setOrigin(1, 0).setDepth(10);
|
||||||
|
|
||||||
|
// Cycle info (top-right, below spores)
|
||||||
|
const cycleInfo = `Великий Цикл ${cycleSummary.cycleNumber}: ${themeName} | Ран ${cycleSummary.runInCycle}/${cycleSummary.totalRuns}`;
|
||||||
|
this.add.text(GAME_WIDTH - 12, 46, cycleInfo, {
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#336644',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
}).setOrigin(1, 0).setDepth(10);
|
||||||
|
|
||||||
|
// Cycle narrative quote (top-left, fades in slowly)
|
||||||
|
const themeNarrative = (narrativeData.themes as Record<string, { cradleQuoteRu: string }>)[cycleSummary.theme];
|
||||||
|
if (themeNarrative) {
|
||||||
|
const quoteText = this.add.text(12, 12, themeNarrative.cradleQuoteRu, {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#223322',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
});
|
||||||
|
quoteText.setAlpha(0);
|
||||||
|
quoteText.setDepth(10);
|
||||||
|
this.tweens.add({
|
||||||
|
targets: quoteText,
|
||||||
|
alpha: 0.5,
|
||||||
|
duration: 3000,
|
||||||
|
delay: 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private showSchoolSelection(): void {
|
||||||
|
const cx = GAME_WIDTH / 2;
|
||||||
|
|
||||||
|
// Title
|
||||||
|
const title = this.add.text(cx, 30, 'СПОРОВАЯ КОЛЫБЕЛЬ', {
|
||||||
|
fontSize: '28px',
|
||||||
|
color: '#00ff88',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontStyle: 'bold',
|
||||||
|
});
|
||||||
|
title.setOrigin(0.5);
|
||||||
|
title.setAlpha(0);
|
||||||
|
title.setDepth(10);
|
||||||
|
|
||||||
|
const subtitle = this.add.text(cx, 65, 'Выбери биом и школу', {
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#557755',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
});
|
||||||
|
subtitle.setOrigin(0.5);
|
||||||
|
subtitle.setAlpha(0);
|
||||||
|
subtitle.setDepth(10);
|
||||||
|
|
||||||
|
this.tweens.add({ targets: [title, subtitle], alpha: 1, duration: 800 });
|
||||||
|
|
||||||
|
// Biome selection row
|
||||||
|
this.createBiomeSelector(cx, 105);
|
||||||
|
|
||||||
|
// School cards — show ALL schools, grayed out if locked
|
||||||
|
const cardWidth = 280;
|
||||||
|
const cardHeight = 220;
|
||||||
|
const startY = 150;
|
||||||
|
const cardSpacing = 12;
|
||||||
|
|
||||||
|
for (let i = 0; i < schools.length; i++) {
|
||||||
|
const school = schools[i];
|
||||||
|
const unlocked = isSchoolUnlocked(this.meta, school.id);
|
||||||
|
const totalCardWidth = schools.length * (cardWidth + cardSpacing) - cardSpacing;
|
||||||
|
const cardX = cx - totalCardWidth / 2 + cardWidth / 2 + i * (cardWidth + cardSpacing);
|
||||||
|
const cardY = startY + cardHeight / 2;
|
||||||
|
|
||||||
|
const container = this.add.container(cardX, cardY);
|
||||||
|
container.setDepth(10);
|
||||||
|
|
||||||
|
const schoolColor = parseInt(school.color.replace('#', ''), 16);
|
||||||
|
|
||||||
|
// Card background
|
||||||
|
const bg = this.add.rectangle(0, 0, cardWidth, cardHeight,
|
||||||
|
unlocked ? 0x0a1a0f : 0x0a0a0a, 0.9);
|
||||||
|
bg.setStrokeStyle(2, unlocked ? schoolColor : 0x333333);
|
||||||
|
container.add(bg);
|
||||||
|
|
||||||
|
// School name
|
||||||
|
const nameText = this.add.text(0, -cardHeight / 2 + 20, school.nameRu, {
|
||||||
|
fontSize: '20px',
|
||||||
|
color: unlocked ? school.color : '#555555',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontStyle: 'bold',
|
||||||
|
});
|
||||||
|
nameText.setOrigin(0.5);
|
||||||
|
container.add(nameText);
|
||||||
|
|
||||||
|
// Principle
|
||||||
|
const principleText = this.add.text(0, -cardHeight / 2 + 48, school.principleRu, {
|
||||||
|
fontSize: '11px',
|
||||||
|
color: unlocked ? '#88aa88' : '#444444',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
});
|
||||||
|
principleText.setOrigin(0.5);
|
||||||
|
container.add(principleText);
|
||||||
|
|
||||||
|
if (unlocked) {
|
||||||
|
// ─── Unlocked school: full info ───
|
||||||
|
|
||||||
|
// Starting elements
|
||||||
|
const elemList = school.startingElements.map(sym => {
|
||||||
|
const qty = school.startingQuantities[sym] ?? 1;
|
||||||
|
return `${sym} ×${qty}`;
|
||||||
|
}).join(' ');
|
||||||
|
const elemText = this.add.text(0, -cardHeight / 2 + 72, elemList, {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#aaffaa',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
});
|
||||||
|
elemText.setOrigin(0.5);
|
||||||
|
container.add(elemText);
|
||||||
|
|
||||||
|
// Bonus indicator
|
||||||
|
const bonusKeys = Object.keys(school.bonuses);
|
||||||
|
if (bonusKeys.length > 0) {
|
||||||
|
const bonusLabels: Record<string, string> = {
|
||||||
|
reactionEfficiency: 'Реакции',
|
||||||
|
projectileDamage: 'Урон',
|
||||||
|
creatureAggroRange: 'Агрессия',
|
||||||
|
movementSpeed: 'Скорость',
|
||||||
|
};
|
||||||
|
const bonusText = bonusKeys.map(k => {
|
||||||
|
const label = bonusLabels[k] ?? k;
|
||||||
|
const val = school.bonuses[k];
|
||||||
|
const pct = val >= 1 ? `+${Math.round((val - 1) * 100)}%` : `-${Math.round((1 - val) * 100)}%`;
|
||||||
|
return `${label} ${pct}`;
|
||||||
|
}).join(' ');
|
||||||
|
const bText = this.add.text(0, -cardHeight / 2 + 95, bonusText, {
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#ffcc44',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
});
|
||||||
|
bText.setOrigin(0.5);
|
||||||
|
container.add(bText);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playstyle
|
||||||
|
const playText = this.add.text(0, -cardHeight / 2 + 118,
|
||||||
|
this.wrapText(school.playstyleRu, 30), {
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#778877',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
lineSpacing: 3,
|
||||||
|
});
|
||||||
|
playText.setOrigin(0.5, 0);
|
||||||
|
container.add(playText);
|
||||||
|
|
||||||
|
// Description
|
||||||
|
const descText = this.add.text(0, cardHeight / 2 - 55,
|
||||||
|
this.wrapText(school.descriptionRu, 30), {
|
||||||
|
fontSize: '10px',
|
||||||
|
color: '#556655',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
lineSpacing: 2,
|
||||||
|
});
|
||||||
|
descText.setOrigin(0.5, 0);
|
||||||
|
container.add(descText);
|
||||||
|
|
||||||
|
// Click to select
|
||||||
|
bg.setInteractive({ useHandCursor: true });
|
||||||
|
bg.on('pointerover', () => {
|
||||||
|
bg.setStrokeStyle(3, 0x00ff88);
|
||||||
|
this.selectedIndex = i;
|
||||||
|
});
|
||||||
|
bg.on('pointerout', () => {
|
||||||
|
bg.setStrokeStyle(2, schoolColor);
|
||||||
|
});
|
||||||
|
bg.on('pointerdown', () => {
|
||||||
|
this.startRun(school);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// ─── Locked school: grayed out with unlock hint ───
|
||||||
|
|
||||||
|
// Lock icon
|
||||||
|
const lockText = this.add.text(0, -10, '🔒', {
|
||||||
|
fontSize: '32px',
|
||||||
|
});
|
||||||
|
lockText.setOrigin(0.5);
|
||||||
|
container.add(lockText);
|
||||||
|
|
||||||
|
// Unlock hint
|
||||||
|
const hint = school.unlockCondition?.hintRu ?? 'Заблокировано';
|
||||||
|
const hintText = this.add.text(0, 30, hint, {
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#666666',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
align: 'center',
|
||||||
|
});
|
||||||
|
hintText.setOrigin(0.5);
|
||||||
|
container.add(hintText);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fade in
|
||||||
|
container.setAlpha(0);
|
||||||
|
this.tweens.add({
|
||||||
|
targets: container,
|
||||||
|
alpha: unlocked ? 1 : 0.6,
|
||||||
|
duration: 600,
|
||||||
|
delay: 200 + i * 150,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.schoolCards.push(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spore shop (bottom area, only if player has spores)
|
||||||
|
if (this.meta.spores > 0) {
|
||||||
|
this.createSporeShop(cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start button hint
|
||||||
|
const hintText = this.add.text(cx, GAME_HEIGHT - 40, '[ Нажми на школу, чтобы начать ран ]', {
|
||||||
|
fontSize: '13px',
|
||||||
|
color: '#445544',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
});
|
||||||
|
hintText.setOrigin(0.5);
|
||||||
|
hintText.setAlpha(0);
|
||||||
|
hintText.setDepth(10);
|
||||||
|
this.tweens.add({
|
||||||
|
targets: hintText,
|
||||||
|
alpha: 0.8,
|
||||||
|
duration: 600,
|
||||||
|
delay: 600,
|
||||||
|
yoyo: true,
|
||||||
|
repeat: -1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create the spore shop UI below school cards */
|
||||||
|
private createSporeShop(cx: number): void {
|
||||||
|
this.shopContainer = this.add.container(0, 0);
|
||||||
|
this.shopContainer.setDepth(10);
|
||||||
|
|
||||||
|
const shopY = 440;
|
||||||
|
const shopTitle = this.add.text(cx, shopY, '🍄 Дары Мицелия (потрать споры)', {
|
||||||
|
fontSize: '13px',
|
||||||
|
color: '#33aa66',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
});
|
||||||
|
shopTitle.setOrigin(0.5);
|
||||||
|
this.shopContainer.add(shopTitle);
|
||||||
|
|
||||||
|
const bonuses = getAvailableBonuses();
|
||||||
|
const btnWidth = 200;
|
||||||
|
const btnHeight = 50;
|
||||||
|
const spacing = 10;
|
||||||
|
const totalWidth = bonuses.length * (btnWidth + spacing) - spacing;
|
||||||
|
const startX = cx - totalWidth / 2 + btnWidth / 2;
|
||||||
|
|
||||||
|
for (let i = 0; i < bonuses.length; i++) {
|
||||||
|
const bonus = bonuses[i];
|
||||||
|
const bx = startX + i * (btnWidth + spacing);
|
||||||
|
const by = shopY + 45;
|
||||||
|
|
||||||
|
const bg = this.add.rectangle(bx, by, btnWidth, btnHeight, 0x0a1a0f, 0.85);
|
||||||
|
const canBuy = canAffordBonus(this.meta, bonus.id);
|
||||||
|
const borderColor = canBuy ? 0x33cc66 : 0x333333;
|
||||||
|
bg.setStrokeStyle(1, borderColor);
|
||||||
|
this.shopContainer.add(bg);
|
||||||
|
|
||||||
|
const label = this.add.text(bx, by - 10, bonus.nameRu, {
|
||||||
|
fontSize: '11px',
|
||||||
|
color: canBuy ? '#88ffaa' : '#555555',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
});
|
||||||
|
label.setOrigin(0.5);
|
||||||
|
this.shopContainer.add(label);
|
||||||
|
|
||||||
|
const costText = this.add.text(bx, by + 10, `${bonus.cost} спор`, {
|
||||||
|
fontSize: '10px',
|
||||||
|
color: canBuy ? '#44aa66' : '#444444',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
});
|
||||||
|
costText.setOrigin(0.5);
|
||||||
|
this.shopContainer.add(costText);
|
||||||
|
|
||||||
|
if (canBuy) {
|
||||||
|
bg.setInteractive({ useHandCursor: true });
|
||||||
|
bg.on('pointerover', () => bg.setStrokeStyle(2, 0x00ff88));
|
||||||
|
bg.on('pointerout', () => bg.setStrokeStyle(1, 0x33cc66));
|
||||||
|
bg.on('pointerdown', () => {
|
||||||
|
const effect = purchaseBonus(this.meta, bonus.id);
|
||||||
|
if (effect) {
|
||||||
|
this.purchasedEffects.push(effect);
|
||||||
|
this.sporeCountText.setText(`🍄 Споры: ${this.meta.spores}`);
|
||||||
|
|
||||||
|
// Visual feedback
|
||||||
|
bg.setFillStyle(0x1a3a1f, 0.9);
|
||||||
|
label.setColor('#ffffff');
|
||||||
|
costText.setText('✓ куплено');
|
||||||
|
costText.setColor('#00ff88');
|
||||||
|
bg.removeInteractive();
|
||||||
|
|
||||||
|
// Refresh other buttons' affordability
|
||||||
|
this.refreshShopButtons();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fade in shop
|
||||||
|
this.shopContainer.setAlpha(0);
|
||||||
|
this.tweens.add({
|
||||||
|
targets: this.shopContainer,
|
||||||
|
alpha: 1,
|
||||||
|
duration: 600,
|
||||||
|
delay: 800,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refresh shop button states after a purchase */
|
||||||
|
private refreshShopButtons(): void {
|
||||||
|
// Destroy and recreate shop — simplest approach
|
||||||
|
if (this.shopContainer) {
|
||||||
|
this.shopContainer.destroy();
|
||||||
|
}
|
||||||
|
if (this.meta.spores > 0) {
|
||||||
|
this.createSporeShop(GAME_WIDTH / 2);
|
||||||
|
this.shopContainer.setAlpha(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create the biome selection row */
|
||||||
|
private createBiomeSelector(cx: number, y: number): void {
|
||||||
|
const btnWidth = 200;
|
||||||
|
const btnHeight = 36;
|
||||||
|
const spacing = 12;
|
||||||
|
const totalWidth = biomes.length * (btnWidth + spacing) - spacing;
|
||||||
|
const startX = cx - totalWidth / 2 + btnWidth / 2;
|
||||||
|
|
||||||
|
const biomeColors: Record<string, number> = {
|
||||||
|
'catalytic-wastes': 0x886622,
|
||||||
|
'kinetic-mountains': 0x5577aa,
|
||||||
|
'verdant-forests': 0x228833,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < biomes.length; i++) {
|
||||||
|
const biome = biomes[i];
|
||||||
|
const bx = startX + i * (btnWidth + spacing);
|
||||||
|
const isSelected = i === this.selectedBiomeIndex;
|
||||||
|
const color = biomeColors[biome.id] ?? 0x444444;
|
||||||
|
|
||||||
|
const bg = this.add.rectangle(bx, y, btnWidth, btnHeight,
|
||||||
|
isSelected ? color : 0x0a1a0f, 0.9);
|
||||||
|
bg.setStrokeStyle(isSelected ? 3 : 1, color);
|
||||||
|
bg.setDepth(10);
|
||||||
|
|
||||||
|
const label = this.add.text(bx, y, biome.nameRu, {
|
||||||
|
fontSize: '13px',
|
||||||
|
color: isSelected ? '#ffffff' : '#888888',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontStyle: isSelected ? 'bold' : 'normal',
|
||||||
|
});
|
||||||
|
label.setOrigin(0.5);
|
||||||
|
label.setDepth(10);
|
||||||
|
|
||||||
|
bg.setInteractive({ useHandCursor: true });
|
||||||
|
bg.on('pointerdown', () => {
|
||||||
|
this.selectedBiomeIndex = i;
|
||||||
|
this.refreshBiomeButtons();
|
||||||
|
});
|
||||||
|
bg.on('pointerover', () => {
|
||||||
|
if (i !== this.selectedBiomeIndex) {
|
||||||
|
bg.setStrokeStyle(2, 0x00ff88);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
bg.on('pointerout', () => {
|
||||||
|
const sel = i === this.selectedBiomeIndex;
|
||||||
|
bg.setStrokeStyle(sel ? 3 : 1, color);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fade in
|
||||||
|
bg.setAlpha(0);
|
||||||
|
label.setAlpha(0);
|
||||||
|
this.tweens.add({ targets: [bg, label], alpha: 1, duration: 600, delay: 100 + i * 100 });
|
||||||
|
|
||||||
|
this.biomeCards.push({ bg, label });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refresh biome button visual states */
|
||||||
|
private refreshBiomeButtons(): void {
|
||||||
|
const biomeColors: Record<string, number> = {
|
||||||
|
'catalytic-wastes': 0x886622,
|
||||||
|
'kinetic-mountains': 0x5577aa,
|
||||||
|
'verdant-forests': 0x228833,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < this.biomeCards.length; i++) {
|
||||||
|
const card = this.biomeCards[i];
|
||||||
|
const biome = biomes[i];
|
||||||
|
const isSelected = i === this.selectedBiomeIndex;
|
||||||
|
const color = biomeColors[biome.id] ?? 0x444444;
|
||||||
|
|
||||||
|
card.bg.setFillStyle(isSelected ? color : 0x0a1a0f, 0.9);
|
||||||
|
card.bg.setStrokeStyle(isSelected ? 3 : 1, color);
|
||||||
|
card.label.setColor(isSelected ? '#ffffff' : '#888888');
|
||||||
|
card.label.setFontStyle(isSelected ? 'bold' : 'normal');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startRun(school: SchoolData): void {
|
||||||
|
// Flash effect
|
||||||
|
this.cameras.main.flash(300, 0, 255, 136);
|
||||||
|
|
||||||
|
const selectedBiome = biomes[this.selectedBiomeIndex];
|
||||||
|
|
||||||
|
this.time.delayedCall(400, () => {
|
||||||
|
this.scene.start('GameScene', {
|
||||||
|
meta: this.meta,
|
||||||
|
schoolId: school.id,
|
||||||
|
runId: this.meta.totalRuns + 1,
|
||||||
|
purchasedEffects: this.purchasedEffects,
|
||||||
|
biomeId: selectedBiome.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
update(_time: number, delta: number): void {
|
||||||
|
// Animate spore particles
|
||||||
|
this.particleGraphics.clear();
|
||||||
|
for (const p of this.particles) {
|
||||||
|
p.x += p.vx * (delta / 1000);
|
||||||
|
p.y += p.vy * (delta / 1000);
|
||||||
|
p.alpha += (Math.random() - 0.5) * 0.01;
|
||||||
|
p.alpha = Math.max(0.05, Math.min(0.5, p.alpha));
|
||||||
|
|
||||||
|
// Wrap around
|
||||||
|
if (p.y < -5) p.y = GAME_HEIGHT + 5;
|
||||||
|
if (p.x < -5) p.x = GAME_WIDTH + 5;
|
||||||
|
if (p.x > GAME_WIDTH + 5) p.x = -5;
|
||||||
|
|
||||||
|
this.particleGraphics.fillStyle(0x00ff88, p.alpha);
|
||||||
|
this.particleGraphics.fillCircle(p.x, p.y, p.radius);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simple word wrap by character count */
|
||||||
|
private wrapText(text: string, maxChars: number): string {
|
||||||
|
const words = text.split(' ');
|
||||||
|
const lines: string[] = [];
|
||||||
|
let currentLine = '';
|
||||||
|
for (const word of words) {
|
||||||
|
if (currentLine.length + word.length + 1 > maxChars) {
|
||||||
|
lines.push(currentLine);
|
||||||
|
currentLine = word;
|
||||||
|
} else {
|
||||||
|
currentLine += (currentLine.length > 0 ? ' ' : '') + word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentLine.length > 0) lines.push(currentLine);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
261
src/scenes/DeathScene.ts
Normal file
261
src/scenes/DeathScene.ts
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
/**
|
||||||
|
* DeathScene — Body decomposition and transition to fractal
|
||||||
|
*
|
||||||
|
* GDD spec:
|
||||||
|
* 1. World slows, sounds fade
|
||||||
|
* 2. Body decomposes into real elements (65% O, 18% C, 10% H...)
|
||||||
|
* 3. Elements absorbed into soil → rush toward Mycelium
|
||||||
|
* 4. Transition to FractalScene
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Phaser from 'phaser';
|
||||||
|
import type { MetaState, RunState } from '../run/types';
|
||||||
|
import { BODY_COMPOSITION } from '../run/types';
|
||||||
|
import { ElementRegistry } from '../chemistry/elements';
|
||||||
|
import { GAME_WIDTH, GAME_HEIGHT } from '../config';
|
||||||
|
|
||||||
|
/** A single element particle flying out during decomposition */
|
||||||
|
interface DeathParticle {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
color: number;
|
||||||
|
symbol: string;
|
||||||
|
alpha: number;
|
||||||
|
radius: number;
|
||||||
|
phase: 'explode' | 'settle' | 'absorb';
|
||||||
|
timer: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeathScene extends Phaser.Scene {
|
||||||
|
private meta!: MetaState;
|
||||||
|
private runState!: RunState;
|
||||||
|
private particles: DeathParticle[] = [];
|
||||||
|
private graphics!: Phaser.GameObjects.Graphics;
|
||||||
|
private elapsed = 0;
|
||||||
|
private phaseState: 'decompose' | 'absorb' | 'fadeout' = 'decompose';
|
||||||
|
private labelTexts: Phaser.GameObjects.Text[] = [];
|
||||||
|
private compositionText!: Phaser.GameObjects.Text;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super({ key: 'DeathScene' });
|
||||||
|
}
|
||||||
|
|
||||||
|
init(data: { meta: MetaState; runState: RunState }): void {
|
||||||
|
this.meta = data.meta;
|
||||||
|
this.runState = data.runState;
|
||||||
|
this.particles = [];
|
||||||
|
this.elapsed = 0;
|
||||||
|
this.phaseState = 'decompose';
|
||||||
|
this.labelTexts = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
create(): void {
|
||||||
|
this.cameras.main.setBackgroundColor('#020202');
|
||||||
|
this.graphics = this.add.graphics();
|
||||||
|
|
||||||
|
const cx = GAME_WIDTH / 2;
|
||||||
|
const cy = GAME_HEIGHT / 2;
|
||||||
|
|
||||||
|
// Player "body" — starts as a bright circle, then decomposes
|
||||||
|
const bodyGlow = this.add.circle(cx, cy, 16, 0x00e5ff, 1);
|
||||||
|
bodyGlow.setDepth(10);
|
||||||
|
|
||||||
|
// Title text
|
||||||
|
const deathText = this.add.text(cx, 60, 'Распад...', {
|
||||||
|
fontSize: '20px',
|
||||||
|
color: '#334455',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
});
|
||||||
|
deathText.setOrigin(0.5);
|
||||||
|
deathText.setAlpha(0);
|
||||||
|
|
||||||
|
// Composition display
|
||||||
|
this.compositionText = this.add.text(cx, GAME_HEIGHT - 50, '', {
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#445566',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
});
|
||||||
|
this.compositionText.setOrigin(0.5);
|
||||||
|
this.compositionText.setAlpha(0);
|
||||||
|
|
||||||
|
// Phase 1: Body pulses, then explodes into elements
|
||||||
|
this.tweens.add({
|
||||||
|
targets: bodyGlow,
|
||||||
|
scaleX: 1.5,
|
||||||
|
scaleY: 1.5,
|
||||||
|
alpha: 0.7,
|
||||||
|
duration: 600,
|
||||||
|
yoyo: true,
|
||||||
|
repeat: 2,
|
||||||
|
ease: 'Sine.easeInOut',
|
||||||
|
onComplete: () => {
|
||||||
|
// Explode into element particles
|
||||||
|
this.spawnElementParticles(cx, cy);
|
||||||
|
bodyGlow.destroy();
|
||||||
|
|
||||||
|
// Show death text
|
||||||
|
this.tweens.add({
|
||||||
|
targets: deathText,
|
||||||
|
alpha: 0.6,
|
||||||
|
duration: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show composition
|
||||||
|
const compText = BODY_COMPOSITION
|
||||||
|
.map(e => `${e.symbol}: ${(e.fraction * 100).toFixed(1)}%`)
|
||||||
|
.join(' ');
|
||||||
|
this.compositionText.setText(`Элементный состав: ${compText}`);
|
||||||
|
this.tweens.add({
|
||||||
|
targets: this.compositionText,
|
||||||
|
alpha: 0.5,
|
||||||
|
duration: 2000,
|
||||||
|
delay: 500,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// After 6 seconds, start absorb phase
|
||||||
|
this.time.delayedCall(5500, () => {
|
||||||
|
this.phaseState = 'absorb';
|
||||||
|
this.tweens.add({
|
||||||
|
targets: deathText,
|
||||||
|
alpha: 0,
|
||||||
|
duration: 800,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// After 8 seconds, transition to fractal
|
||||||
|
this.time.delayedCall(8000, () => {
|
||||||
|
this.phaseState = 'fadeout';
|
||||||
|
this.cameras.main.fadeOut(1500, 0, 0, 0);
|
||||||
|
this.cameras.main.once('camerafadeoutcomplete', () => {
|
||||||
|
this.scene.start('FractalScene', {
|
||||||
|
meta: this.meta,
|
||||||
|
runState: this.runState,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private spawnElementParticles(cx: number, cy: number): void {
|
||||||
|
const totalParticles = 80;
|
||||||
|
|
||||||
|
for (const comp of BODY_COMPOSITION) {
|
||||||
|
const count = Math.max(1, Math.round(comp.fraction * totalParticles));
|
||||||
|
const elem = ElementRegistry.getBySymbol(comp.symbol);
|
||||||
|
const color = elem ? parseInt(elem.color.replace('#', ''), 16) : 0xffffff;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const angle = Math.random() * Math.PI * 2;
|
||||||
|
const speed = 40 + Math.random() * 120;
|
||||||
|
this.particles.push({
|
||||||
|
x: cx + (Math.random() - 0.5) * 6,
|
||||||
|
y: cy + (Math.random() - 0.5) * 6,
|
||||||
|
vx: Math.cos(angle) * speed,
|
||||||
|
vy: Math.sin(angle) * speed,
|
||||||
|
color,
|
||||||
|
symbol: comp.symbol,
|
||||||
|
alpha: 0.8 + Math.random() * 0.2,
|
||||||
|
radius: 2 + comp.fraction * 6,
|
||||||
|
phase: 'explode',
|
||||||
|
timer: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a visible label for the most abundant elements
|
||||||
|
if (comp.fraction >= 0.03) {
|
||||||
|
const labelAngle = Math.random() * Math.PI * 2;
|
||||||
|
const labelDist = 60 + comp.fraction * 200;
|
||||||
|
const labelText = this.add.text(
|
||||||
|
cx + Math.cos(labelAngle) * labelDist,
|
||||||
|
cy + Math.sin(labelAngle) * labelDist,
|
||||||
|
`${comp.symbol} ${(comp.fraction * 100).toFixed(0)}%`,
|
||||||
|
{
|
||||||
|
fontSize: '13px',
|
||||||
|
color: `#${color.toString(16).padStart(6, '0')}`,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
labelText.setOrigin(0.5);
|
||||||
|
labelText.setAlpha(0);
|
||||||
|
this.tweens.add({
|
||||||
|
targets: labelText,
|
||||||
|
alpha: 0.7,
|
||||||
|
duration: 800,
|
||||||
|
delay: 200 + Math.random() * 600,
|
||||||
|
});
|
||||||
|
this.labelTexts.push(labelText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update(_time: number, delta: number): void {
|
||||||
|
this.elapsed += delta;
|
||||||
|
const dt = delta / 1000;
|
||||||
|
|
||||||
|
this.graphics.clear();
|
||||||
|
|
||||||
|
const cx = GAME_WIDTH / 2;
|
||||||
|
const cy = GAME_HEIGHT / 2;
|
||||||
|
|
||||||
|
for (const p of this.particles) {
|
||||||
|
p.timer += delta;
|
||||||
|
|
||||||
|
if (this.phaseState === 'decompose') {
|
||||||
|
// Particles fly outward, then slow down
|
||||||
|
p.vx *= 0.98;
|
||||||
|
p.vy *= 0.98;
|
||||||
|
p.x += p.vx * dt;
|
||||||
|
p.y += p.vy * dt;
|
||||||
|
} else if (this.phaseState === 'absorb') {
|
||||||
|
// Particles get pulled toward center-bottom (into "soil"/Mycelium)
|
||||||
|
const targetX = cx + (Math.random() - 0.5) * 20;
|
||||||
|
const targetY = GAME_HEIGHT + 20;
|
||||||
|
const dx = targetX - p.x;
|
||||||
|
const dy = targetY - p.y;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (dist > 1) {
|
||||||
|
p.vx += (dx / dist) * 200 * dt;
|
||||||
|
p.vy += (dy / dist) * 200 * dt;
|
||||||
|
}
|
||||||
|
p.x += p.vx * dt;
|
||||||
|
p.y += p.vy * dt;
|
||||||
|
p.alpha *= 0.995;
|
||||||
|
} else {
|
||||||
|
// Fadeout — everything fades
|
||||||
|
p.alpha *= 0.97;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw particle
|
||||||
|
if (p.alpha > 0.02) {
|
||||||
|
this.graphics.fillStyle(p.color, p.alpha);
|
||||||
|
this.graphics.fillCircle(p.x, p.y, p.radius);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fade labels during absorb
|
||||||
|
if (this.phaseState === 'absorb' || this.phaseState === 'fadeout') {
|
||||||
|
for (const label of this.labelTexts) {
|
||||||
|
label.setAlpha(label.alpha * 0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw "Mycelium threads" rushing down during absorb
|
||||||
|
if (this.phaseState === 'absorb') {
|
||||||
|
this.graphics.lineStyle(1, 0x00ff88, 0.15);
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const tx = cx + (Math.random() - 0.5) * 200;
|
||||||
|
this.graphics.beginPath();
|
||||||
|
this.graphics.moveTo(tx, cy);
|
||||||
|
let y = cy;
|
||||||
|
for (let j = 0; j < 8; j++) {
|
||||||
|
y += 30 + Math.random() * 20;
|
||||||
|
this.graphics.lineTo(tx + (Math.random() - 0.5) * 30, y);
|
||||||
|
}
|
||||||
|
this.graphics.strokePath();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
335
src/scenes/FractalScene.ts
Normal file
335
src/scenes/FractalScene.ts
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
/**
|
||||||
|
* FractalScene — "Момент между" (Moment Between)
|
||||||
|
*
|
||||||
|
* GDD spec:
|
||||||
|
* 10-30 seconds of pure fractal visual. Myriad cycles flash before eyes.
|
||||||
|
* Looping patterns of births and deaths, infinitely nested.
|
||||||
|
* This is a REWARD, not punishment for dying.
|
||||||
|
*
|
||||||
|
* Uses a WebGL shader (Mandelbrot/Julia set hybrid) with cycling parameters.
|
||||||
|
* Falls back to a simpler canvas animation if WebGL pipeline unavailable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Phaser from 'phaser';
|
||||||
|
import type { MetaState, RunState } from '../run/types';
|
||||||
|
import { applyRunResults } from '../run/meta';
|
||||||
|
import { saveMetaState } from '../run/persistence';
|
||||||
|
import { GAME_WIDTH, GAME_HEIGHT } from '../config';
|
||||||
|
|
||||||
|
// ─── Fractal Fragment Shader ─────────────────────────────────────
|
||||||
|
|
||||||
|
const FRACTAL_FRAG = `
|
||||||
|
precision mediump float;
|
||||||
|
|
||||||
|
uniform float uTime;
|
||||||
|
uniform vec2 uResolution;
|
||||||
|
uniform float uZoom;
|
||||||
|
uniform vec2 uCenter;
|
||||||
|
uniform float uMorphFactor;
|
||||||
|
|
||||||
|
vec3 palette(float t) {
|
||||||
|
// Cyclic color palette — greens/teals/purples (Synthesis color scheme)
|
||||||
|
vec3 a = vec3(0.02, 0.05, 0.03);
|
||||||
|
vec3 b = vec3(0.0, 0.6, 0.4);
|
||||||
|
vec3 c = vec3(1.0, 1.0, 1.0);
|
||||||
|
vec3 d = vec3(0.0, 0.33, 0.53);
|
||||||
|
return a + b * cos(6.28318 * (c * t + d));
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec2 uv = (gl_FragCoord.xy - uResolution * 0.5) / min(uResolution.x, uResolution.y);
|
||||||
|
uv = uv / uZoom + uCenter;
|
||||||
|
|
||||||
|
// Morph between Mandelbrot and Julia set
|
||||||
|
vec2 c = mix(uv, vec2(
|
||||||
|
0.355 + sin(uTime * 0.1) * 0.1,
|
||||||
|
0.355 + cos(uTime * 0.13) * 0.1
|
||||||
|
), uMorphFactor);
|
||||||
|
|
||||||
|
vec2 z = mix(vec2(0.0), uv, uMorphFactor);
|
||||||
|
z = mix(uv, z, uMorphFactor);
|
||||||
|
|
||||||
|
float iterations = 0.0;
|
||||||
|
const float maxIter = 128.0;
|
||||||
|
|
||||||
|
for (float i = 0.0; i < maxIter; i++) {
|
||||||
|
// z = z^2 + c with slight twist
|
||||||
|
float xTemp = z.x * z.x - z.y * z.y + c.x;
|
||||||
|
z.y = 2.0 * z.x * z.y + c.y;
|
||||||
|
z.x = xTemp;
|
||||||
|
|
||||||
|
if (dot(z, z) > 4.0) {
|
||||||
|
iterations = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
iterations = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smooth coloring
|
||||||
|
float smoothIter = iterations;
|
||||||
|
if (iterations < maxIter - 1.0) {
|
||||||
|
float logZn = log(dot(z, z)) / 2.0;
|
||||||
|
float nu = log(logZn / log(2.0)) / log(2.0);
|
||||||
|
smoothIter = iterations + 1.0 - nu;
|
||||||
|
}
|
||||||
|
|
||||||
|
float t = smoothIter / maxIter;
|
||||||
|
t = t + uTime * 0.03; // Slow color cycling
|
||||||
|
|
||||||
|
vec3 color = palette(t);
|
||||||
|
|
||||||
|
// Vignette — darker at edges
|
||||||
|
vec2 vigUv = gl_FragCoord.xy / uResolution;
|
||||||
|
float vig = 1.0 - dot(vigUv - 0.5, vigUv - 0.5) * 1.5;
|
||||||
|
color *= vig;
|
||||||
|
|
||||||
|
// Inside the set — deep dark with faint glow
|
||||||
|
if (iterations >= maxIter - 1.0) {
|
||||||
|
color = vec3(0.0, 0.02, 0.01) + 0.03 * sin(uTime * vec3(0.7, 1.1, 0.9));
|
||||||
|
}
|
||||||
|
|
||||||
|
gl_FragColor = vec4(color, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/** Minimal vertex shader — just pass through */
|
||||||
|
const FRACTAL_VERT = `
|
||||||
|
precision mediump float;
|
||||||
|
attribute vec2 inPosition;
|
||||||
|
void main() {
|
||||||
|
gl_Position = vec4(inPosition, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export class FractalScene extends Phaser.Scene {
|
||||||
|
private meta!: MetaState;
|
||||||
|
private runState!: RunState;
|
||||||
|
private elapsed = 0;
|
||||||
|
private duration = 12000; // 12 seconds default
|
||||||
|
private shaderEnabled = false;
|
||||||
|
private customPipeline: Phaser.Renderer.WebGL.WebGLPipeline | null = null;
|
||||||
|
private isRenewal = false;
|
||||||
|
private previousCycle = 1;
|
||||||
|
|
||||||
|
// Fallback canvas animation state
|
||||||
|
private fallbackGraphics!: Phaser.GameObjects.Graphics;
|
||||||
|
private fallbackParticles: { x: number; y: number; r: number; angle: number; speed: number; color: number }[] = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super({ key: 'FractalScene' });
|
||||||
|
}
|
||||||
|
|
||||||
|
init(data: { meta: MetaState; runState: RunState }): void {
|
||||||
|
this.meta = data.meta;
|
||||||
|
this.runState = data.runState;
|
||||||
|
this.elapsed = 0;
|
||||||
|
|
||||||
|
// More discoveries → longer fractal (reward for exploration)
|
||||||
|
const discCount = this.runState.discoveries.elements.size
|
||||||
|
+ this.runState.discoveries.reactions.size
|
||||||
|
+ this.runState.discoveries.compounds.size
|
||||||
|
+ this.runState.discoveries.creatures.size;
|
||||||
|
this.duration = Math.min(25000, 10000 + discCount * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
create(): void {
|
||||||
|
this.cameras.main.setBackgroundColor('#000000');
|
||||||
|
|
||||||
|
// Apply run results to meta and save; check for Great Renewal
|
||||||
|
this.previousCycle = this.meta.greatCycle.cycleNumber;
|
||||||
|
this.isRenewal = applyRunResults(this.meta, this.runState);
|
||||||
|
saveMetaState(this.meta).catch(() => {
|
||||||
|
// Silently fail — game continues even if persistence fails
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try to create WebGL shader
|
||||||
|
this.shaderEnabled = this.tryCreateShader();
|
||||||
|
|
||||||
|
if (!this.shaderEnabled) {
|
||||||
|
this.createFallbackAnimation();
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Spores earned" text (appears after a moment)
|
||||||
|
const sporesText = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 60,
|
||||||
|
`+${this.meta.runHistory[this.meta.runHistory.length - 1]?.sporesEarned ?? 0} спор`, {
|
||||||
|
fontSize: '16px',
|
||||||
|
color: '#00ff88',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
});
|
||||||
|
sporesText.setOrigin(0.5);
|
||||||
|
sporesText.setAlpha(0);
|
||||||
|
sporesText.setDepth(100);
|
||||||
|
|
||||||
|
this.tweens.add({
|
||||||
|
targets: sporesText,
|
||||||
|
alpha: 0.8,
|
||||||
|
duration: 1000,
|
||||||
|
delay: 3000,
|
||||||
|
onComplete: () => {
|
||||||
|
this.tweens.add({
|
||||||
|
targets: sporesText,
|
||||||
|
alpha: 0,
|
||||||
|
duration: 2000,
|
||||||
|
delay: 3000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Skip hint
|
||||||
|
const skipText = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 25,
|
||||||
|
'[ click to skip ]', {
|
||||||
|
fontSize: '10px',
|
||||||
|
color: '#333333',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
});
|
||||||
|
skipText.setOrigin(0.5);
|
||||||
|
skipText.setDepth(100);
|
||||||
|
|
||||||
|
// Click to skip (after minimum 3 seconds)
|
||||||
|
this.input.on('pointerdown', () => {
|
||||||
|
if (this.elapsed > 3000) {
|
||||||
|
this.transitionToCradle();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private tryCreateShader(): boolean {
|
||||||
|
try {
|
||||||
|
const renderer = this.renderer;
|
||||||
|
if (!(renderer instanceof Phaser.Renderer.WebGL.WebGLRenderer)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Phaser's shader support via a fullscreen quad
|
||||||
|
const baseShader = new Phaser.Display.BaseShader(
|
||||||
|
'fractal',
|
||||||
|
FRACTAL_FRAG,
|
||||||
|
FRACTAL_VERT,
|
||||||
|
{
|
||||||
|
uTime: { type: '1f', value: 0.0 },
|
||||||
|
uResolution: { type: '2f', value: { x: GAME_WIDTH, y: GAME_HEIGHT } },
|
||||||
|
uZoom: { type: '1f', value: 0.5 },
|
||||||
|
uCenter: { type: '2f', value: { x: -0.5, y: 0.0 } },
|
||||||
|
uMorphFactor: { type: '1f', value: 0.0 },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const shader = this.add.shader(baseShader, GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT);
|
||||||
|
shader.setDepth(0);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createFallbackAnimation(): void {
|
||||||
|
// Canvas-based fractal-ish animation as fallback
|
||||||
|
this.fallbackGraphics = this.add.graphics();
|
||||||
|
this.fallbackParticles = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
const angle = Math.random() * Math.PI * 2;
|
||||||
|
const dist = 20 + Math.random() * 300;
|
||||||
|
this.fallbackParticles.push({
|
||||||
|
x: GAME_WIDTH / 2 + Math.cos(angle) * dist,
|
||||||
|
y: GAME_HEIGHT / 2 + Math.sin(angle) * dist,
|
||||||
|
r: 1 + Math.random() * 4,
|
||||||
|
angle,
|
||||||
|
speed: 0.2 + Math.random() * 0.8,
|
||||||
|
color: Phaser.Display.Color.HSLToColor(
|
||||||
|
0.3 + Math.random() * 0.3,
|
||||||
|
0.6,
|
||||||
|
0.3 + Math.random() * 0.3,
|
||||||
|
).color,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update(_time: number, delta: number): void {
|
||||||
|
this.elapsed += delta;
|
||||||
|
|
||||||
|
if (this.shaderEnabled) {
|
||||||
|
// Update shader uniforms via the shader game object
|
||||||
|
const shaders = this.children.list.filter(
|
||||||
|
(child): child is Phaser.GameObjects.Shader => child instanceof Phaser.GameObjects.Shader,
|
||||||
|
);
|
||||||
|
if (shaders.length > 0) {
|
||||||
|
const shader = shaders[0];
|
||||||
|
const t = this.elapsed / 1000;
|
||||||
|
|
||||||
|
shader.setUniform('uTime.value', t);
|
||||||
|
|
||||||
|
// Slowly zoom in
|
||||||
|
const zoom = 0.5 + t * 0.03;
|
||||||
|
shader.setUniform('uZoom.value', zoom);
|
||||||
|
|
||||||
|
// Slowly morph from Mandelbrot to Julia
|
||||||
|
const morph = Math.min(1.0, t / (this.duration / 1000) * 2);
|
||||||
|
shader.setUniform('uMorphFactor.value', morph);
|
||||||
|
|
||||||
|
// Pan slowly
|
||||||
|
shader.setUniform('uCenter.value.x', -0.5 + Math.sin(t * 0.05) * 0.2);
|
||||||
|
shader.setUniform('uCenter.value.y', Math.cos(t * 0.07) * 0.15);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback animation
|
||||||
|
this.updateFallbackAnimation(delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-transition after duration
|
||||||
|
if (this.elapsed >= this.duration) {
|
||||||
|
this.transitionToCradle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateFallbackAnimation(delta: number): void {
|
||||||
|
if (!this.fallbackGraphics) return;
|
||||||
|
|
||||||
|
this.fallbackGraphics.clear();
|
||||||
|
const cx = GAME_WIDTH / 2;
|
||||||
|
const cy = GAME_HEIGHT / 2;
|
||||||
|
const t = this.elapsed / 1000;
|
||||||
|
|
||||||
|
for (const p of this.fallbackParticles) {
|
||||||
|
// Spiral motion
|
||||||
|
p.angle += p.speed * (delta / 1000);
|
||||||
|
const dist = 50 + Math.sin(t * 0.5 + p.angle * 3) * 100 + Math.cos(t * 0.3) * 50;
|
||||||
|
p.x = cx + Math.cos(p.angle) * dist;
|
||||||
|
p.y = cy + Math.sin(p.angle) * dist;
|
||||||
|
|
||||||
|
const alpha = 0.3 + Math.sin(t + p.angle * 2) * 0.3;
|
||||||
|
this.fallbackGraphics.fillStyle(p.color, Math.max(0.05, alpha));
|
||||||
|
this.fallbackGraphics.fillCircle(p.x, p.y, p.r);
|
||||||
|
|
||||||
|
// Connect nearby particles with dim lines
|
||||||
|
if (Math.random() < 0.02) {
|
||||||
|
const other = this.fallbackParticles[Math.floor(Math.random() * this.fallbackParticles.length)];
|
||||||
|
this.fallbackGraphics.lineStyle(1, 0x00ff88, 0.05);
|
||||||
|
this.fallbackGraphics.beginPath();
|
||||||
|
this.fallbackGraphics.moveTo(p.x, p.y);
|
||||||
|
this.fallbackGraphics.lineTo(other.x, other.y);
|
||||||
|
this.fallbackGraphics.strokePath();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private transitionToCradle(): void {
|
||||||
|
// Prevent double-transition
|
||||||
|
if ((this as unknown as { _transitioning?: boolean })._transitioning) return;
|
||||||
|
(this as unknown as { _transitioning?: boolean })._transitioning = true;
|
||||||
|
|
||||||
|
this.cameras.main.fadeOut(1500, 0, 0, 0);
|
||||||
|
this.cameras.main.once('camerafadeoutcomplete', () => {
|
||||||
|
if (this.isRenewal) {
|
||||||
|
// Great Renewal — show special scene before returning to Cradle
|
||||||
|
this.scene.start('RenewalScene', {
|
||||||
|
meta: this.meta,
|
||||||
|
previousCycle: this.previousCycle,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.scene.start('CradleScene', { meta: this.meta });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,50 +1,264 @@
|
|||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
import { createGameWorld, updateTime, type GameWorld } from '../ecs/world';
|
import { createGameWorld, updateTime, type GameWorld } from '../ecs/world';
|
||||||
|
import { Health, Position, Creature, LifeCycle } from '../ecs/components';
|
||||||
import { movementSystem } from '../ecs/systems/movement';
|
import { movementSystem } from '../ecs/systems/movement';
|
||||||
import { healthSystem } from '../ecs/systems/health';
|
import { healthSystem } from '../ecs/systems/health';
|
||||||
import { removeGameEntity } from '../ecs/factory';
|
import { removeGameEntity } from '../ecs/factory';
|
||||||
import { PhaserBridge } from '../ecs/bridge';
|
import { PhaserBridge } from '../ecs/bridge';
|
||||||
import biomeDataArray from '../data/biomes.json';
|
import biomeDataArray from '../data/biomes.json';
|
||||||
|
import speciesDataArray from '../data/creatures.json';
|
||||||
|
import schoolsData from '../data/schools.json';
|
||||||
import type { BiomeData } from '../world/types';
|
import type { BiomeData } from '../world/types';
|
||||||
import { generateWorld } from '../world/generator';
|
import { generateWorld } from '../world/generator';
|
||||||
import { createWorldTilemap } from '../world/tilemap';
|
import { createWorldTilemap } from '../world/tilemap';
|
||||||
import { setupCamera, updateCamera, type CameraKeys } from '../world/camera';
|
import { setupPlayerCamera } from '../world/camera';
|
||||||
import { Minimap } from '../world/minimap';
|
import { Minimap } from '../world/minimap';
|
||||||
|
import { playerInputSystem } from '../player/input';
|
||||||
|
import { tileCollisionSystem, buildWalkableSet } from '../player/collision';
|
||||||
|
import { findSpawnPosition } from '../player/spawn';
|
||||||
|
import { createPlayerEntity } from '../player/factory';
|
||||||
|
import { Inventory } from '../player/inventory';
|
||||||
|
import { interactionSystem, type ResourceInfo } from '../player/interaction';
|
||||||
|
import { spawnResources } from '../world/resources';
|
||||||
|
import {
|
||||||
|
launchProjectile,
|
||||||
|
projectileSystem,
|
||||||
|
type ProjectileData,
|
||||||
|
} from '../player/projectile';
|
||||||
|
import { QuickSlots } from '../player/quickslots';
|
||||||
|
import type { InputState } from '../player/types';
|
||||||
|
import type { SpeciesData, CreatureInfo } from '../creatures/types';
|
||||||
|
import { SpeciesRegistry } from '../creatures/types';
|
||||||
|
import { aiSystem } from '../creatures/ai';
|
||||||
|
import { metabolismSystem, clearMetabolismTracking } from '../creatures/metabolism';
|
||||||
|
import { lifeCycleSystem } from '../creatures/lifecycle';
|
||||||
|
import {
|
||||||
|
countPopulations,
|
||||||
|
spawnInitialCreatures,
|
||||||
|
reproduce,
|
||||||
|
} from '../creatures/population';
|
||||||
|
import {
|
||||||
|
creatureProjectileSystem,
|
||||||
|
getObservableCreatures,
|
||||||
|
creatureAttackPlayerSystem,
|
||||||
|
} from '../creatures/interaction';
|
||||||
|
import { query } from 'bitecs';
|
||||||
|
|
||||||
|
// Run cycle imports
|
||||||
|
import type { MetaState, SchoolData, RunState, ResolvedSchoolBonuses } from '../run/types';
|
||||||
|
import { RunPhase, RUN_PHASE_NAMES_RU, PHASE_DURATIONS } from '../run/types';
|
||||||
|
import { getSchoolBonuses } from '../run/meta';
|
||||||
|
import { createRunState, advancePhase, updateEscalation, recordDiscovery } from '../run/state';
|
||||||
|
import {
|
||||||
|
createCrisisState,
|
||||||
|
applyCrisisDamage,
|
||||||
|
attemptNeutralize,
|
||||||
|
getCrisisPlayerDamage,
|
||||||
|
getCrisisTint,
|
||||||
|
CHEMICAL_PLAGUE,
|
||||||
|
type CrisisState,
|
||||||
|
} from '../run/crisis';
|
||||||
|
import { getEscalationEffects } from '../run/escalation';
|
||||||
|
|
||||||
|
// UI zoom compensation
|
||||||
|
import { fixToScreen } from '../ui/screen-fix';
|
||||||
|
|
||||||
|
// Mycelium imports
|
||||||
|
import { FungalNode } from '../ecs/components';
|
||||||
|
import { spawnFungalNodes } from '../mycelium/nodes';
|
||||||
|
import { depositKnowledge } from '../mycelium/graph';
|
||||||
|
import { extractMemoryFlashes } from '../mycelium/knowledge';
|
||||||
|
import { createMycosisState, updateMycosis, getMycosisVisuals } from '../mycelium/mycosis';
|
||||||
|
import type { FungalNodeInfo, MycosisState, MemoryFlash } from '../mycelium/types';
|
||||||
|
import { FUNGAL_NODE_CONFIG, MYCOSIS_CONFIG } from '../mycelium/types';
|
||||||
|
|
||||||
|
// World traces (Great Cycle)
|
||||||
|
import { spawnWorldTraces, updateTraceGlow, type WorldTraceInfo } from '../world/traces';
|
||||||
|
import { CYCLE_THEME_NAMES_RU } from '../run/types';
|
||||||
|
|
||||||
export class GameScene extends Phaser.Scene {
|
export class GameScene extends Phaser.Scene {
|
||||||
private gameWorld!: GameWorld;
|
private gameWorld!: GameWorld;
|
||||||
private bridge!: PhaserBridge;
|
private bridge!: PhaserBridge;
|
||||||
private cameraKeys!: CameraKeys;
|
|
||||||
private minimap!: Minimap;
|
private minimap!: Minimap;
|
||||||
private statsText!: Phaser.GameObjects.Text;
|
private statsText!: Phaser.GameObjects.Text;
|
||||||
private worldSeed!: number;
|
private worldSeed!: number;
|
||||||
|
|
||||||
|
// Player state
|
||||||
|
private playerEid!: number;
|
||||||
|
private inventory!: Inventory;
|
||||||
|
private walkableSet!: Set<number>;
|
||||||
|
private worldGrid!: number[][];
|
||||||
|
private tileSize!: number;
|
||||||
|
private resourceData!: Map<number, ResourceInfo>;
|
||||||
|
private projectileData!: Map<number, ProjectileData>;
|
||||||
|
private quickSlots!: QuickSlots;
|
||||||
|
private keys!: {
|
||||||
|
W: Phaser.Input.Keyboard.Key;
|
||||||
|
A: Phaser.Input.Keyboard.Key;
|
||||||
|
S: Phaser.Input.Keyboard.Key;
|
||||||
|
D: Phaser.Input.Keyboard.Key;
|
||||||
|
E: Phaser.Input.Keyboard.Key;
|
||||||
|
F: Phaser.Input.Keyboard.Key;
|
||||||
|
ONE: Phaser.Input.Keyboard.Key;
|
||||||
|
TWO: Phaser.Input.Keyboard.Key;
|
||||||
|
THREE: Phaser.Input.Keyboard.Key;
|
||||||
|
FOUR: Phaser.Input.Keyboard.Key;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Creature state
|
||||||
|
private speciesRegistry!: SpeciesRegistry;
|
||||||
|
private speciesLookup!: Map<number, SpeciesData>;
|
||||||
|
private creatureData!: Map<number, CreatureInfo>;
|
||||||
|
|
||||||
|
// Interaction feedback
|
||||||
|
private interactionText!: Phaser.GameObjects.Text;
|
||||||
|
private interactionTimer = 0;
|
||||||
|
private wasEDown = false;
|
||||||
|
private wasFDown = false;
|
||||||
|
|
||||||
|
// Run cycle state
|
||||||
|
private meta!: MetaState;
|
||||||
|
private runState!: RunState;
|
||||||
|
private crisisState: CrisisState | null = null;
|
||||||
|
private phaseText!: Phaser.GameObjects.Text;
|
||||||
|
private playerDead = false;
|
||||||
|
private crisisOverlay!: Phaser.GameObjects.Rectangle;
|
||||||
|
|
||||||
|
// Mycelium state
|
||||||
|
private fungalNodeData!: Map<number, FungalNodeInfo>;
|
||||||
|
private mycosisState!: MycosisState;
|
||||||
|
private mycosisOverlay!: Phaser.GameObjects.Rectangle;
|
||||||
|
private memoryFlashText!: Phaser.GameObjects.Text;
|
||||||
|
private memoryFlashTimer = 0;
|
||||||
|
private fungalNodeGlowGraphics!: Phaser.GameObjects.Graphics;
|
||||||
|
private hasDepositedThisRun = false;
|
||||||
|
|
||||||
|
// World traces from past runs
|
||||||
|
private worldTraceData: WorldTraceInfo[] = [];
|
||||||
|
private worldTraceGraphics!: Phaser.GameObjects.Graphics;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super({ key: 'GameScene' });
|
super({ key: 'GameScene' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Purchased bonuses from Cradle shop
|
||||||
|
private purchasedEffects: import('../mycelium/types').BonusEffect[] = [];
|
||||||
|
|
||||||
|
// Biome selection
|
||||||
|
private biomeId = 'catalytic-wastes';
|
||||||
|
|
||||||
|
// School bonuses (resolved from school data)
|
||||||
|
private schoolBonuses!: ResolvedSchoolBonuses;
|
||||||
|
|
||||||
|
init(data: { meta: MetaState; schoolId: string; runId: number; purchasedEffects?: import('../mycelium/types').BonusEffect[]; biomeId?: string }): void {
|
||||||
|
this.meta = data.meta;
|
||||||
|
this.biomeId = data.biomeId ?? 'catalytic-wastes';
|
||||||
|
this.runState = createRunState(data.runId, data.schoolId, this.biomeId);
|
||||||
|
this.crisisState = null;
|
||||||
|
this.playerDead = false;
|
||||||
|
this.mycosisState = createMycosisState();
|
||||||
|
this.hasDepositedThisRun = false;
|
||||||
|
this.memoryFlashTimer = 0;
|
||||||
|
this.purchasedEffects = data.purchasedEffects ?? [];
|
||||||
|
this.schoolBonuses = getSchoolBonuses(data.schoolId);
|
||||||
|
}
|
||||||
|
|
||||||
create(): void {
|
create(): void {
|
||||||
// 1. Initialize ECS (needed for future entity systems)
|
// 1. Initialize ECS
|
||||||
this.gameWorld = createGameWorld();
|
this.gameWorld = createGameWorld();
|
||||||
this.bridge = new PhaserBridge(this);
|
this.bridge = new PhaserBridge(this);
|
||||||
|
this.projectileData = new Map();
|
||||||
|
|
||||||
// 2. Generate world
|
// 2. Generate world — use selected biome
|
||||||
const biome = biomeDataArray[0] as BiomeData;
|
const biome = (biomeDataArray as BiomeData[]).find(b => b.id === this.biomeId) ?? biomeDataArray[0] as BiomeData;
|
||||||
this.worldSeed = Date.now() % 1000000;
|
this.worldSeed = Date.now() % 1000000;
|
||||||
|
this.runState.worldSeed = this.worldSeed;
|
||||||
const worldData = generateWorld(biome, this.worldSeed);
|
const worldData = generateWorld(biome, this.worldSeed);
|
||||||
|
|
||||||
// 3. Create tilemap
|
// 3. Create tilemap
|
||||||
createWorldTilemap(this, worldData);
|
createWorldTilemap(this, worldData);
|
||||||
|
|
||||||
// 4. Camera with bounds and WASD controls
|
// 4. Build walkable set + store world data for collision
|
||||||
|
this.walkableSet = buildWalkableSet(biome.tiles);
|
||||||
|
this.worldGrid = worldData.grid;
|
||||||
|
this.tileSize = biome.tileSize;
|
||||||
|
|
||||||
|
// 5. Spawn resource entities (mineral veins, geysers)
|
||||||
|
this.resourceData = spawnResources(
|
||||||
|
this.gameWorld.world, worldData.grid, biome, this.worldSeed,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5b. Spawn fungal nodes (Mycelium surface points)
|
||||||
|
this.fungalNodeData = spawnFungalNodes(
|
||||||
|
this.gameWorld.world, worldData.grid, biome, this.worldSeed,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5c. Spawn world traces from past runs (Great Cycle)
|
||||||
|
this.worldTraceData = spawnWorldTraces(
|
||||||
|
this.gameWorld.world, this.meta.greatCycle, this.biomeId, biome,
|
||||||
|
);
|
||||||
|
this.worldTraceGraphics = this.add.graphics();
|
||||||
|
this.worldTraceGraphics.setDepth(4); // above tiles, below entities
|
||||||
|
|
||||||
|
// 6. Initialize creature systems — filter by biome
|
||||||
|
const allSpecies = speciesDataArray as SpeciesData[];
|
||||||
|
const biomeSpecies = allSpecies.filter(s => s.biome === biome.id);
|
||||||
|
this.speciesRegistry = new SpeciesRegistry(biomeSpecies);
|
||||||
|
this.speciesLookup = new Map<number, SpeciesData>();
|
||||||
|
for (const s of biomeSpecies) {
|
||||||
|
this.speciesLookup.set(s.speciesId, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Spawn creatures across the map
|
||||||
|
this.creatureData = spawnInitialCreatures(
|
||||||
|
this.gameWorld.world, worldData.grid, biome, this.worldSeed, biomeSpecies,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 8. Create player at spawn position + inventory with starting kit
|
||||||
|
const spawn = findSpawnPosition(worldData.grid, biome.tileSize, this.walkableSet);
|
||||||
|
const spawnX = spawn?.x ?? (biome.mapWidth * biome.tileSize) / 2;
|
||||||
|
const spawnY = spawn?.y ?? (biome.mapHeight * biome.tileSize) / 2;
|
||||||
|
this.playerEid = createPlayerEntity(this.gameWorld.world, spawnX, spawnY);
|
||||||
|
this.inventory = new Inventory(500, 20);
|
||||||
|
this.quickSlots = new QuickSlots();
|
||||||
|
|
||||||
|
// Give starting elements from chosen school + apply purchased bonuses
|
||||||
|
this.giveStartingKit();
|
||||||
|
this.applyPurchasedEffects();
|
||||||
|
|
||||||
|
// 9. Camera — follow player, zoom via scroll wheel
|
||||||
const worldPixelW = biome.mapWidth * biome.tileSize;
|
const worldPixelW = biome.mapWidth * biome.tileSize;
|
||||||
const worldPixelH = biome.mapHeight * biome.tileSize;
|
const worldPixelH = biome.mapHeight * biome.tileSize;
|
||||||
this.cameraKeys = setupCamera(this, worldPixelW, worldPixelH);
|
setupPlayerCamera(this, worldPixelW, worldPixelH);
|
||||||
|
|
||||||
// 5. Minimap
|
// Sync bridge to create sprites, then attach camera follow to player
|
||||||
|
this.bridge.sync(this.gameWorld.world);
|
||||||
|
const playerSprite = this.bridge.getSprite(this.playerEid);
|
||||||
|
if (playerSprite) {
|
||||||
|
playerSprite.setDepth(10);
|
||||||
|
this.cameras.main.startFollow(playerSprite, true, 0.1, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Keyboard input
|
||||||
|
const keyboard = this.input.keyboard;
|
||||||
|
if (!keyboard) throw new Error('Keyboard plugin not available');
|
||||||
|
this.keys = {
|
||||||
|
W: keyboard.addKey('W'),
|
||||||
|
A: keyboard.addKey('A'),
|
||||||
|
S: keyboard.addKey('S'),
|
||||||
|
D: keyboard.addKey('D'),
|
||||||
|
E: keyboard.addKey('E'),
|
||||||
|
F: keyboard.addKey('F'),
|
||||||
|
ONE: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ONE),
|
||||||
|
TWO: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.TWO),
|
||||||
|
THREE: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.THREE),
|
||||||
|
FOUR: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.FOUR),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 9. Minimap
|
||||||
this.minimap = new Minimap(this, worldData);
|
this.minimap = new Minimap(this, worldData);
|
||||||
|
|
||||||
// 6. UI overlay
|
// 10. UI overlay
|
||||||
this.statsText = this.add.text(10, 10, '', {
|
this.statsText = this.add.text(10, 10, '', {
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
color: '#00ff88',
|
color: '#00ff88',
|
||||||
@@ -54,30 +268,707 @@ export class GameScene extends Phaser.Scene {
|
|||||||
});
|
});
|
||||||
this.statsText.setScrollFactor(0);
|
this.statsText.setScrollFactor(0);
|
||||||
this.statsText.setDepth(100);
|
this.statsText.setDepth(100);
|
||||||
|
|
||||||
|
// Interaction feedback text (center-bottom of screen)
|
||||||
|
this.interactionText = this.add.text(
|
||||||
|
this.cameras.main.width / 2, this.cameras.main.height - 40, '', {
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#ffdd44',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
backgroundColor: '#000000cc',
|
||||||
|
padding: { x: 6, y: 3 },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.interactionText.setScrollFactor(0);
|
||||||
|
this.interactionText.setOrigin(0.5);
|
||||||
|
this.interactionText.setDepth(100);
|
||||||
|
this.interactionText.setAlpha(0);
|
||||||
|
|
||||||
|
// Phase indicator (top-center)
|
||||||
|
this.phaseText = this.add.text(
|
||||||
|
this.cameras.main.width / 2, 12, '', {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#00ff88',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
backgroundColor: '#000000aa',
|
||||||
|
padding: { x: 6, y: 2 },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.phaseText.setScrollFactor(0);
|
||||||
|
this.phaseText.setOrigin(0.5, 0);
|
||||||
|
this.phaseText.setDepth(100);
|
||||||
|
|
||||||
|
// Crisis overlay (full-screen tinted rectangle, hidden by default)
|
||||||
|
this.crisisOverlay = this.add.rectangle(
|
||||||
|
this.cameras.main.width / 2, this.cameras.main.height / 2,
|
||||||
|
this.cameras.main.width, this.cameras.main.height,
|
||||||
|
0x88ff88, 0,
|
||||||
|
);
|
||||||
|
this.crisisOverlay.setScrollFactor(0);
|
||||||
|
this.crisisOverlay.setDepth(90);
|
||||||
|
|
||||||
|
// 10b. Mycosis overlay (full-screen tinted rectangle, hidden by default)
|
||||||
|
this.mycosisOverlay = this.add.rectangle(
|
||||||
|
this.cameras.main.width / 2, this.cameras.main.height / 2,
|
||||||
|
this.cameras.main.width, this.cameras.main.height,
|
||||||
|
MYCOSIS_CONFIG.tintColor, 0,
|
||||||
|
);
|
||||||
|
this.mycosisOverlay.setScrollFactor(0);
|
||||||
|
this.mycosisOverlay.setDepth(91);
|
||||||
|
|
||||||
|
// 10c. Fungal node glow graphics (world-space, under entities)
|
||||||
|
this.fungalNodeGlowGraphics = this.add.graphics();
|
||||||
|
this.fungalNodeGlowGraphics.setDepth(2);
|
||||||
|
|
||||||
|
// 10d. Memory flash text (center of screen, fades in/out)
|
||||||
|
this.memoryFlashText = this.add.text(
|
||||||
|
this.cameras.main.width / 2, this.cameras.main.height / 2, '', {
|
||||||
|
fontSize: '16px',
|
||||||
|
color: '#88ffaa',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
backgroundColor: '#0a1a0fcc',
|
||||||
|
padding: { x: 12, y: 8 },
|
||||||
|
align: 'center',
|
||||||
|
wordWrap: { width: 400 },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.memoryFlashText.setScrollFactor(0);
|
||||||
|
this.memoryFlashText.setOrigin(0.5);
|
||||||
|
this.memoryFlashText.setDepth(105);
|
||||||
|
this.memoryFlashText.setAlpha(0);
|
||||||
|
|
||||||
|
// 11. Launch UIScene overlay
|
||||||
|
this.scene.launch('UIScene');
|
||||||
|
|
||||||
|
// Transition from Awakening to Exploration after a moment
|
||||||
|
this.time.delayedCall(500, () => {
|
||||||
|
advancePhase(this.runState); // Awakening → Exploration
|
||||||
|
});
|
||||||
|
|
||||||
|
// Debug: expose kill method for testing death cycle
|
||||||
|
(this as unknown as Record<string, unknown>).__debugKill = () => {
|
||||||
|
Health.current[this.playerEid] = 0;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply purchased spore bonuses from the Cradle shop */
|
||||||
|
private applyPurchasedEffects(): void {
|
||||||
|
for (const effect of this.purchasedEffects) {
|
||||||
|
switch (effect.type) {
|
||||||
|
case 'extra_health':
|
||||||
|
Health.max[this.playerEid] = (Health.max[this.playerEid] ?? 100) + effect.amount;
|
||||||
|
Health.current[this.playerEid] = Health.max[this.playerEid];
|
||||||
|
break;
|
||||||
|
case 'extra_element':
|
||||||
|
for (let i = 0; i < effect.quantity; i++) {
|
||||||
|
this.inventory.addItem(effect.symbol);
|
||||||
|
}
|
||||||
|
this.quickSlots.autoAssign(effect.symbol);
|
||||||
|
break;
|
||||||
|
case 'knowledge_boost':
|
||||||
|
// Stored for use when extracting memory flashes
|
||||||
|
// (increases clarity of extracted flashes)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Give the player their school's starting elements */
|
||||||
|
private giveStartingKit(): void {
|
||||||
|
const schools = schoolsData as unknown as SchoolData[];
|
||||||
|
const school = schools.find(s => s.id === this.runState.schoolId);
|
||||||
|
if (!school) return;
|
||||||
|
|
||||||
|
for (const symbol of school.startingElements) {
|
||||||
|
const qty = school.startingQuantities[symbol] ?? 1;
|
||||||
|
for (let i = 0; i < qty; i++) {
|
||||||
|
this.inventory.addItem(symbol);
|
||||||
|
}
|
||||||
|
this.quickSlots.autoAssign(symbol);
|
||||||
|
|
||||||
|
// Record discovery of starting elements
|
||||||
|
recordDiscovery(this.runState, 'element', symbol);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
update(_time: number, delta: number): void {
|
update(_time: number, delta: number): void {
|
||||||
|
// Skip updates if death transition is in progress
|
||||||
|
if (this.playerDead) return;
|
||||||
|
|
||||||
// 1. Update world time
|
// 1. Update world time
|
||||||
updateTime(this.gameWorld, delta);
|
updateTime(this.gameWorld, delta);
|
||||||
|
|
||||||
// 2. Camera movement
|
// 1a. Update run state timers
|
||||||
updateCamera(this, this.cameraKeys, delta);
|
this.runState.elapsed += delta;
|
||||||
|
this.runState.phaseTimer += delta;
|
||||||
|
this.updateRunPhase(delta);
|
||||||
|
|
||||||
// 3. ECS systems (no entities yet — future phases will add player, creatures)
|
// 2. Read keyboard → InputState
|
||||||
|
const input: InputState = {
|
||||||
|
moveX: (this.keys.D.isDown ? 1 : 0) - (this.keys.A.isDown ? 1 : 0),
|
||||||
|
moveY: (this.keys.S.isDown ? 1 : 0) - (this.keys.W.isDown ? 1 : 0),
|
||||||
|
interact: this.keys.E.isDown,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Player input → velocity (Navigator school gets speed bonus)
|
||||||
|
playerInputSystem(this.gameWorld.world, input, this.schoolBonuses.movementSpeed);
|
||||||
|
|
||||||
|
// 4. Movement (all entities including projectiles)
|
||||||
movementSystem(this.gameWorld.world, delta);
|
movementSystem(this.gameWorld.world, delta);
|
||||||
const dead = healthSystem(this.gameWorld.world);
|
|
||||||
for (const eid of dead) {
|
// 5. Tile collision (player only)
|
||||||
removeGameEntity(this.gameWorld.world, eid);
|
tileCollisionSystem(
|
||||||
|
this.gameWorld.world,
|
||||||
|
delta,
|
||||||
|
this.worldGrid,
|
||||||
|
this.tileSize,
|
||||||
|
this.walkableSet,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6. Projectile system (lifetime + tile collision)
|
||||||
|
projectileSystem(
|
||||||
|
this.gameWorld.world,
|
||||||
|
delta,
|
||||||
|
this.worldGrid,
|
||||||
|
this.tileSize,
|
||||||
|
this.walkableSet,
|
||||||
|
this.projectileData,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 7. E key interaction (debounced) — fungal node has priority, then resources
|
||||||
|
const isEDown = this.keys.E.isDown;
|
||||||
|
const justPressedE = isEDown && !this.wasEDown;
|
||||||
|
this.wasEDown = isEDown;
|
||||||
|
|
||||||
|
// Check if player is near a fungal node first
|
||||||
|
const nearbyFungalNode = this.getNearestFungalNode();
|
||||||
|
if (justPressedE && nearbyFungalNode !== null) {
|
||||||
|
// Fungal node interaction takes priority
|
||||||
|
this.interactWithFungalNode(nearbyFungalNode);
|
||||||
|
} else {
|
||||||
|
// Normal resource interaction
|
||||||
|
const interaction = interactionSystem(
|
||||||
|
this.gameWorld.world, justPressedE, this.inventory, this.resourceData,
|
||||||
|
);
|
||||||
|
if (interaction) {
|
||||||
|
if (interaction.type === 'collected' || interaction.type === 'depleted') {
|
||||||
|
if (interaction.itemId) {
|
||||||
|
this.quickSlots.autoAssign(interaction.itemId);
|
||||||
|
recordDiscovery(this.runState, 'element', interaction.itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.showInteractionFeedback(interaction.type, interaction.itemId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.bridge.sync(this.gameWorld.world);
|
|
||||||
|
|
||||||
// 4. Minimap viewport
|
// 8. Quick slot selection (1-4 keys)
|
||||||
this.minimap.update(this.cameras.main);
|
if (this.keys.ONE.isDown) this.quickSlots.setActive(0);
|
||||||
|
if (this.keys.TWO.isDown) this.quickSlots.setActive(1);
|
||||||
|
if (this.keys.THREE.isDown) this.quickSlots.setActive(2);
|
||||||
|
if (this.keys.FOUR.isDown) this.quickSlots.setActive(3);
|
||||||
|
|
||||||
// 5. Stats
|
// 9. Throw projectile (F key, debounced) — uses active quick slot
|
||||||
const fps = delta > 0 ? Math.round(1000 / delta) : 0;
|
const isFDown = this.keys.F.isDown;
|
||||||
this.statsText.setText(
|
const justPressedF = isFDown && !this.wasFDown;
|
||||||
`seed: ${this.worldSeed} | ${fps} fps | WASD move, scroll zoom`,
|
this.wasFDown = isFDown;
|
||||||
|
if (justPressedF) {
|
||||||
|
this.tryLaunchProjectile();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9a. Creature AI — aggro range affected by Naturalist school bonus
|
||||||
|
aiSystem(
|
||||||
|
this.gameWorld.world, delta,
|
||||||
|
this.speciesLookup, this.gameWorld.time.tick,
|
||||||
|
this.schoolBonuses.creatureAggroRange,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 9b. Creature metabolism (feeding, energy drain)
|
||||||
|
metabolismSystem(
|
||||||
|
this.gameWorld.world, delta,
|
||||||
|
this.resourceData, this.gameWorld.time.elapsed,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 9c. Creature life cycle (aging, stage transitions)
|
||||||
|
const lcEvents = lifeCycleSystem(
|
||||||
|
this.gameWorld.world, delta, this.speciesLookup,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 9d. Handle reproduction events
|
||||||
|
const populations = countPopulations(this.gameWorld.world);
|
||||||
|
for (const event of lcEvents) {
|
||||||
|
if (event.type === 'ready_to_reproduce') {
|
||||||
|
const species = this.speciesLookup.get(event.speciesId);
|
||||||
|
if (species) {
|
||||||
|
const currentPop = populations.get(event.speciesId) ?? 0;
|
||||||
|
reproduce(
|
||||||
|
this.gameWorld.world, event.eid, species,
|
||||||
|
currentPop, this.creatureData,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9e. Projectile-creature collision (Mechanic school gets damage bonus)
|
||||||
|
creatureProjectileSystem(
|
||||||
|
this.gameWorld.world, this.projectileData, this.speciesLookup,
|
||||||
|
this.schoolBonuses.projectileDamage,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 9f. Creature attacks on player
|
||||||
|
creatureAttackPlayerSystem(this.gameWorld.world, this.speciesLookup);
|
||||||
|
|
||||||
|
// 9g. Mycelium — fungal node glow, interaction, mycosis
|
||||||
|
this.updateMycelium(delta);
|
||||||
|
|
||||||
|
// 9h. Crisis damage (if active)
|
||||||
|
if (this.crisisState?.active) {
|
||||||
|
applyCrisisDamage(this.crisisState, delta);
|
||||||
|
const crisisDmg = getCrisisPlayerDamage(this.crisisState, delta);
|
||||||
|
if (crisisDmg > 0) {
|
||||||
|
Health.current[this.playerEid] = Math.max(
|
||||||
|
0,
|
||||||
|
(Health.current[this.playerEid] ?? 0) - crisisDmg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Update crisis visual tint
|
||||||
|
const tint = getCrisisTint(this.crisisState);
|
||||||
|
this.crisisOverlay.setFillStyle(tint.color, tint.alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9i. Environmental damage from high escalation
|
||||||
|
const escalationFx = getEscalationEffects(this.runState.escalation);
|
||||||
|
if (escalationFx.environmentalDamage > 0) {
|
||||||
|
const envDmg = escalationFx.environmentalDamage * (delta / 1000);
|
||||||
|
Health.current[this.playerEid] = Math.max(
|
||||||
|
0,
|
||||||
|
(Health.current[this.playerEid] ?? 0) - envDmg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Health / death
|
||||||
|
const dead = healthSystem(this.gameWorld.world);
|
||||||
|
let playerDied = false;
|
||||||
|
for (const eid of dead) {
|
||||||
|
if (eid === this.playerEid) {
|
||||||
|
playerDied = true;
|
||||||
|
continue; // Don't remove player entity yet
|
||||||
|
}
|
||||||
|
// Clean up creature tracking
|
||||||
|
if (this.creatureData.has(eid)) {
|
||||||
|
clearMetabolismTracking(eid);
|
||||||
|
this.creatureData.delete(eid);
|
||||||
|
}
|
||||||
|
removeGameEntity(this.gameWorld.world, eid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle player death → transition to DeathScene
|
||||||
|
if (playerDied) {
|
||||||
|
this.onPlayerDeath();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Render sync
|
||||||
|
this.bridge.sync(this.gameWorld.world);
|
||||||
|
|
||||||
|
// 11. Minimap viewport
|
||||||
|
this.minimap.update(this.cameras.main);
|
||||||
|
|
||||||
|
// 12. Fade interaction text
|
||||||
|
if (this.interactionTimer > 0) {
|
||||||
|
this.interactionTimer -= delta;
|
||||||
|
if (this.interactionTimer <= 0) {
|
||||||
|
this.interactionText.setAlpha(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 13. Push shared state to registry for UIScene
|
||||||
|
this.registry.set('health', Health.current[this.playerEid] ?? 100);
|
||||||
|
this.registry.set('healthMax', Health.max[this.playerEid] ?? 100);
|
||||||
|
this.registry.set('quickSlots', this.quickSlots.getAll());
|
||||||
|
this.registry.set('activeSlot', this.quickSlots.activeIndex);
|
||||||
|
this.registry.set('invWeight', this.inventory.getTotalWeight());
|
||||||
|
this.registry.set('invMaxWeight', this.inventory.maxWeight);
|
||||||
|
this.registry.set('invSlots', this.inventory.slotCount);
|
||||||
|
|
||||||
|
// Build counts map for UIScene
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const item of this.inventory.getItems()) {
|
||||||
|
counts.set(item.id, item.count);
|
||||||
|
}
|
||||||
|
this.registry.set('invCounts', counts);
|
||||||
|
|
||||||
|
// 14. Push cycle info to registry for UIScene
|
||||||
|
this.registry.set('cycleNumber', this.meta.greatCycle.cycleNumber);
|
||||||
|
this.registry.set('runInCycle', this.meta.greatCycle.runInCycle);
|
||||||
|
this.registry.set('cycleThemeRu', CYCLE_THEME_NAMES_RU[this.meta.greatCycle.theme]);
|
||||||
|
this.registry.set('runPhaseRu', RUN_PHASE_NAMES_RU[this.runState.phase]);
|
||||||
|
|
||||||
|
// 15. Creature observation for UIScene
|
||||||
|
const nearbyCreatures = getObservableCreatures(this.gameWorld.world);
|
||||||
|
if (nearbyCreatures.length > 0) {
|
||||||
|
const closest = nearbyCreatures[0];
|
||||||
|
const species = this.speciesLookup.get(closest.speciesId);
|
||||||
|
this.registry.set('observedCreature', {
|
||||||
|
name: species?.name ?? 'Unknown',
|
||||||
|
healthPercent: closest.healthPercent,
|
||||||
|
energyPercent: closest.energyPercent,
|
||||||
|
stage: closest.stage,
|
||||||
|
});
|
||||||
|
// Record creature observation as discovery
|
||||||
|
if (species) {
|
||||||
|
recordDiscovery(this.runState, 'creature', species.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.registry.set('observedCreature', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 16. Population counts for debug
|
||||||
|
const popCounts = countPopulations(this.gameWorld.world);
|
||||||
|
this.registry.set('creaturePopulations', popCounts);
|
||||||
|
|
||||||
|
// 17. Debug stats overlay + phase indicator
|
||||||
|
const fps = delta > 0 ? Math.round(1000 / delta) : 0;
|
||||||
|
const px = Math.round(Position.x[this.playerEid]);
|
||||||
|
const py = Math.round(Position.y[this.playerEid]);
|
||||||
|
const creatureCount = [...popCounts.values()].reduce((a, b) => a + b, 0);
|
||||||
|
this.statsText.setText(
|
||||||
|
`seed: ${this.worldSeed} | ${fps} fps | pos: ${px},${py} | creatures: ${creatureCount}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update phase indicator
|
||||||
|
const phaseName = RUN_PHASE_NAMES_RU[this.runState.phase];
|
||||||
|
const escalationPct = Math.round(this.runState.escalation * 100);
|
||||||
|
const crisisInfo = this.crisisState?.active
|
||||||
|
? ` | ☠ ЧУМА: ${Math.round(this.crisisState.progress * 100)}%`
|
||||||
|
: this.runState.crisisResolved ? ' | ✓ Чума нейтрализована' : '';
|
||||||
|
const escFx = getEscalationEffects(this.runState.escalation);
|
||||||
|
const speedInfo = escFx.creatureSpeedMultiplier > 1.05
|
||||||
|
? ` | Агрессия: ×${escFx.creatureAttackMultiplier.toFixed(1)}`
|
||||||
|
: '';
|
||||||
|
this.phaseText.setText(`${phaseName} | Энтропия: ${escalationPct}%${speedInfo}${crisisInfo}`);
|
||||||
|
|
||||||
|
// Color phase text based on danger
|
||||||
|
if (this.runState.phase >= RunPhase.Crisis) {
|
||||||
|
this.phaseText.setColor('#ff4444');
|
||||||
|
} else if (this.runState.phase >= RunPhase.Escalation) {
|
||||||
|
this.phaseText.setColor('#ffaa00');
|
||||||
|
} else {
|
||||||
|
this.phaseText.setColor('#00ff88');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix UI element positions for current camera zoom.
|
||||||
|
// setScrollFactor(0) prevents scroll but NOT zoom displacement.
|
||||||
|
const cam = this.cameras.main;
|
||||||
|
fixToScreen(this.statsText, 10, 30, cam);
|
||||||
|
fixToScreen(this.interactionText, cam.width / 2, cam.height - 40, cam);
|
||||||
|
fixToScreen(this.phaseText, cam.width / 2, 12, cam);
|
||||||
|
fixToScreen(this.memoryFlashText, cam.width / 2, cam.height / 2, cam);
|
||||||
|
fixToScreen(this.crisisOverlay, cam.width / 2, cam.height / 2, cam);
|
||||||
|
fixToScreen(this.mycosisOverlay, cam.width / 2, cam.height / 2, cam);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find the nearest fungal node within interaction range, or null */
|
||||||
|
private getNearestFungalNode(): number | null {
|
||||||
|
const px = Position.x[this.playerEid];
|
||||||
|
const py = Position.y[this.playerEid];
|
||||||
|
const nodeEids = query(this.gameWorld.world, [Position, FungalNode]);
|
||||||
|
|
||||||
|
let nearestEid: number | null = null;
|
||||||
|
let nearestDist = Infinity;
|
||||||
|
|
||||||
|
for (const eid of nodeEids) {
|
||||||
|
const dx = Position.x[eid] - px;
|
||||||
|
const dy = Position.y[eid] - py;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (dist <= FungalNode.interactRange[eid] && dist < nearestDist) {
|
||||||
|
nearestEid = eid;
|
||||||
|
nearestDist = dist;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nearestEid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mycelium system — glow animation, mycosis update, memory flash fade */
|
||||||
|
private updateMycelium(delta: number): void {
|
||||||
|
// Check proximity to any fungal node (for mycosis)
|
||||||
|
const nearNode = this.getNearestFungalNode() !== null;
|
||||||
|
|
||||||
|
// Update mycosis
|
||||||
|
updateMycosis(this.mycosisState, delta, nearNode);
|
||||||
|
const visuals = getMycosisVisuals(this.mycosisState);
|
||||||
|
this.mycosisOverlay.setFillStyle(visuals.tintColor, visuals.tintAlpha);
|
||||||
|
|
||||||
|
// Render fungal node glows
|
||||||
|
const nodeEids = query(this.gameWorld.world, [Position, FungalNode]);
|
||||||
|
this.fungalNodeGlowGraphics.clear();
|
||||||
|
for (const eid of nodeEids) {
|
||||||
|
FungalNode.glowPhase[eid] += FUNGAL_NODE_CONFIG.glowPulseSpeed * (delta / 1000);
|
||||||
|
const phase = FungalNode.glowPhase[eid];
|
||||||
|
const pulse = 0.4 + 0.6 * Math.sin(phase);
|
||||||
|
const radius = FUNGAL_NODE_CONFIG.glowRadius * (0.8 + 0.4 * pulse);
|
||||||
|
const alpha = 0.15 + 0.15 * pulse;
|
||||||
|
|
||||||
|
this.fungalNodeGlowGraphics.fillStyle(FUNGAL_NODE_CONFIG.glowColor, alpha);
|
||||||
|
this.fungalNodeGlowGraphics.fillCircle(
|
||||||
|
Position.x[eid], Position.y[eid], radius,
|
||||||
|
);
|
||||||
|
// Inner brighter glow
|
||||||
|
this.fungalNodeGlowGraphics.fillStyle(FUNGAL_NODE_CONFIG.glowColor, alpha * 1.5);
|
||||||
|
this.fungalNodeGlowGraphics.fillCircle(
|
||||||
|
Position.x[eid], Position.y[eid], radius * 0.5,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render world trace glows (past run markers)
|
||||||
|
this.worldTraceGraphics.clear();
|
||||||
|
updateTraceGlow(this.worldTraceData, delta);
|
||||||
|
for (const trace of this.worldTraceData) {
|
||||||
|
const eid = trace.eid;
|
||||||
|
const phase = (this.worldTraceGraphics as unknown as { _phase?: number })._phase ?? 0;
|
||||||
|
const glowPhase = phase + Position.x[eid] * 0.01; // offset per position
|
||||||
|
const pulse = 0.3 + 0.7 * Math.sin(glowPhase + this.runState.elapsed * 0.001);
|
||||||
|
const color = trace.traceType === 'death_site' ? 0xaa3333 : 0x4488aa;
|
||||||
|
const alpha = 0.1 + 0.1 * pulse;
|
||||||
|
const radius = 8 * (0.7 + 0.3 * pulse);
|
||||||
|
|
||||||
|
this.worldTraceGraphics.fillStyle(color, alpha);
|
||||||
|
this.worldTraceGraphics.fillCircle(Position.x[eid], Position.y[eid], radius);
|
||||||
|
// Inner core
|
||||||
|
this.worldTraceGraphics.fillStyle(color, alpha * 2);
|
||||||
|
this.worldTraceGraphics.fillCircle(Position.x[eid], Position.y[eid], radius * 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fade memory flash text
|
||||||
|
if (this.memoryFlashTimer > 0) {
|
||||||
|
this.memoryFlashTimer -= delta;
|
||||||
|
if (this.memoryFlashTimer <= 500) {
|
||||||
|
this.memoryFlashText.setAlpha(this.memoryFlashTimer / 500);
|
||||||
|
}
|
||||||
|
if (this.memoryFlashTimer <= 0) {
|
||||||
|
this.memoryFlashText.setAlpha(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push mycosis data to registry for UIScene
|
||||||
|
this.registry.set('mycosisLevel', this.mycosisState.level);
|
||||||
|
this.registry.set('mycosisRevealing', this.mycosisState.revealing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle interaction with a fungal node */
|
||||||
|
private interactWithFungalNode(nodeEid: number): void {
|
||||||
|
// First time interacting in this run → deposit discoveries
|
||||||
|
if (!this.hasDepositedThisRun) {
|
||||||
|
const result = depositKnowledge(this.meta.mycelium, this.runState);
|
||||||
|
this.hasDepositedThisRun = true;
|
||||||
|
|
||||||
|
const msg = result.newNodes > 0
|
||||||
|
? `🍄 Мицелий принял ${result.newNodes} знани${result.newNodes === 1 ? 'е' : 'й'}`
|
||||||
|
: `🍄 Мицелий укрепил ${result.strengthened} связ${result.strengthened === 1 ? 'ь' : 'ей'}`;
|
||||||
|
this.showInteractionFeedback('collected', msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract memory flashes from past runs
|
||||||
|
if (this.meta.mycelium.nodes.length > 0) {
|
||||||
|
const flashes = extractMemoryFlashes(this.meta.mycelium, 2);
|
||||||
|
if (flashes.length > 0) {
|
||||||
|
this.showMemoryFlash(flashes[0]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.showInteractionFeedback('collected', '🍄 Мицелий пуст... пока.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Display a memory flash on screen */
|
||||||
|
private showMemoryFlash(flash: MemoryFlash): void {
|
||||||
|
// Show Russian text with clarity indicator
|
||||||
|
const clarityStars = flash.clarity >= 0.7 ? '★★★' : flash.clarity >= 0.4 ? '★★☆' : '★☆☆';
|
||||||
|
const text = `${flash.textRu}\n\n[${clarityStars}]`;
|
||||||
|
this.memoryFlashText.setText(text);
|
||||||
|
this.memoryFlashText.setAlpha(1);
|
||||||
|
this.memoryFlashTimer = 4000; // Show for 4 seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Manage run phase transitions and escalation */
|
||||||
|
private updateRunPhase(delta: number): void {
|
||||||
|
const phase = this.runState.phase;
|
||||||
|
const timer = this.runState.phaseTimer;
|
||||||
|
const duration = PHASE_DURATIONS[phase];
|
||||||
|
|
||||||
|
// Update escalation
|
||||||
|
updateEscalation(this.runState, delta);
|
||||||
|
|
||||||
|
// Auto-advance timed phases
|
||||||
|
if (duration > 0 && timer >= duration) {
|
||||||
|
if (phase === RunPhase.Exploration) {
|
||||||
|
advancePhase(this.runState); // → Escalation
|
||||||
|
} else if (phase === RunPhase.Escalation) {
|
||||||
|
advancePhase(this.runState); // → Crisis
|
||||||
|
this.triggerCrisis();
|
||||||
|
} else if (phase === RunPhase.Resolution) {
|
||||||
|
// Resolution phase complete → enter boss arena
|
||||||
|
this.enterBossArena();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger crisis when escalation hits threshold (even before phase ends)
|
||||||
|
if (
|
||||||
|
phase === RunPhase.Escalation &&
|
||||||
|
!this.crisisState &&
|
||||||
|
this.runState.escalation >= CHEMICAL_PLAGUE.triggerThreshold
|
||||||
|
) {
|
||||||
|
advancePhase(this.runState); // → Crisis
|
||||||
|
this.triggerCrisis();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Activate the Chemical Plague crisis */
|
||||||
|
private triggerCrisis(): void {
|
||||||
|
this.crisisState = createCrisisState(CHEMICAL_PLAGUE);
|
||||||
|
this.runState.crisisActive = true;
|
||||||
|
|
||||||
|
this.showInteractionFeedback('collected', '⚠ ХИМИЧЕСКАЯ ЧУМА! Создай CaO для нейтрализации!');
|
||||||
|
|
||||||
|
// Tint the world slightly toxic via overlay
|
||||||
|
this.crisisOverlay.setFillStyle(0x88ff88, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle player death — start death sequence */
|
||||||
|
private onPlayerDeath(): void {
|
||||||
|
this.playerDead = true;
|
||||||
|
this.runState.alive = false;
|
||||||
|
|
||||||
|
// Record death position for great cycle traces
|
||||||
|
const px = Position.x[this.playerEid] ?? 0;
|
||||||
|
const py = Position.y[this.playerEid] ?? 0;
|
||||||
|
this.runState.deathPosition = {
|
||||||
|
tileX: Math.floor(px / this.tileSize),
|
||||||
|
tileY: Math.floor(py / this.tileSize),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-deposit all discoveries into Mycelium on death
|
||||||
|
if (!this.hasDepositedThisRun) {
|
||||||
|
depositKnowledge(this.meta.mycelium, this.runState);
|
||||||
|
this.hasDepositedThisRun = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop UIScene
|
||||||
|
this.scene.stop('UIScene');
|
||||||
|
|
||||||
|
// Slow-motion effect
|
||||||
|
this.cameras.main.fadeOut(2000, 0, 0, 0);
|
||||||
|
this.cameras.main.once('camerafadeoutcomplete', () => {
|
||||||
|
this.scene.start('DeathScene', {
|
||||||
|
meta: this.meta,
|
||||||
|
runState: this.runState,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Try to launch a projectile from active quick slot toward mouse */
|
||||||
|
private tryLaunchProjectile(): void {
|
||||||
|
const itemId = this.quickSlots.getActive();
|
||||||
|
if (!itemId || !this.inventory.hasItem(itemId)) {
|
||||||
|
this.showInteractionFeedback('nothing_nearby');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed = this.inventory.removeItem(itemId, 1);
|
||||||
|
if (removed === 0) return;
|
||||||
|
|
||||||
|
// Get mouse world position for direction
|
||||||
|
const pointer = this.input.activePointer;
|
||||||
|
const worldPoint = this.cameras.main.getWorldPoint(pointer.x, pointer.y);
|
||||||
|
|
||||||
|
const px = Position.x[this.playerEid];
|
||||||
|
const py = Position.y[this.playerEid];
|
||||||
|
|
||||||
|
launchProjectile(
|
||||||
|
this.gameWorld.world,
|
||||||
|
this.projectileData,
|
||||||
|
px, py,
|
||||||
|
worldPoint.x, worldPoint.y,
|
||||||
|
itemId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if this compound neutralizes the crisis
|
||||||
|
if (this.crisisState?.active && itemId === CHEMICAL_PLAGUE.neutralizer) {
|
||||||
|
attemptNeutralize(this.crisisState, itemId, 1);
|
||||||
|
if (this.crisisState.resolved) {
|
||||||
|
this.runState.crisisResolved = true;
|
||||||
|
this.runState.crisisActive = false;
|
||||||
|
this.crisisOverlay.setFillStyle(0x88ff88, 0);
|
||||||
|
this.showInteractionFeedback('collected', '✓ Чума нейтрализована!');
|
||||||
|
advancePhase(this.runState); // Crisis → Resolution
|
||||||
|
} else {
|
||||||
|
this.showInteractionFeedback('collected', `Нейтрализация: ${this.crisisState.neutralizeApplied}/${CHEMICAL_PLAGUE.neutralizeAmount}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear quick slot if inventory is now empty for this item
|
||||||
|
if (!this.inventory.hasItem(itemId)) {
|
||||||
|
const slotIdx = this.quickSlots.getAll().indexOf(itemId);
|
||||||
|
if (slotIdx >= 0) this.quickSlots.assign(slotIdx, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.crisisState?.active || itemId !== CHEMICAL_PLAGUE.neutralizer) {
|
||||||
|
this.showInteractionFeedback('collected', `Threw ${itemId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Transition to the boss arena (triggered when Resolution phase ends) */
|
||||||
|
private enterBossArena(): void {
|
||||||
|
if (this.playerDead) return;
|
||||||
|
|
||||||
|
// Prevent re-entry
|
||||||
|
this.playerDead = true; // Reuse flag to stop updates
|
||||||
|
|
||||||
|
// Auto-deposit discoveries before leaving
|
||||||
|
if (!this.hasDepositedThisRun) {
|
||||||
|
depositKnowledge(this.meta.mycelium, this.runState);
|
||||||
|
this.hasDepositedThisRun = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showInteractionFeedback('collected', '⚔ Вход в арену Уробороса...');
|
||||||
|
|
||||||
|
// Fade out and transition
|
||||||
|
this.time.delayedCall(1500, () => {
|
||||||
|
this.scene.stop('UIScene');
|
||||||
|
this.cameras.main.fadeOut(1000, 0, 0, 0);
|
||||||
|
this.cameras.main.once('camerafadeoutcomplete', () => {
|
||||||
|
this.scene.start('BossArenaScene', {
|
||||||
|
meta: this.meta,
|
||||||
|
runState: this.runState,
|
||||||
|
inventoryItems: this.inventory.getItems(),
|
||||||
|
quickSlotItems: this.quickSlots.getAll(),
|
||||||
|
activeSlot: this.quickSlots.activeIndex,
|
||||||
|
playerHealth: Health.current[this.playerEid] ?? 100,
|
||||||
|
playerMaxHealth: Health.max[this.playerEid] ?? 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private showInteractionFeedback(type: string, itemId?: string): void {
|
||||||
|
let msg = '';
|
||||||
|
switch (type) {
|
||||||
|
case 'collected':
|
||||||
|
msg = itemId?.startsWith('Threw') || itemId?.startsWith('⚠') || itemId?.startsWith('✓') || itemId?.startsWith('Нейтрализация')
|
||||||
|
? itemId
|
||||||
|
: `+1 ${itemId ?? ''}`;
|
||||||
|
break;
|
||||||
|
case 'depleted':
|
||||||
|
msg = `+1 ${itemId ?? ''} (depleted)`;
|
||||||
|
break;
|
||||||
|
case 'inventory_full':
|
||||||
|
msg = `Inventory full! Can't pick up ${itemId ?? ''}`;
|
||||||
|
break;
|
||||||
|
case 'nothing_nearby':
|
||||||
|
msg = 'Nothing to throw / interact with';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this.interactionText.setText(msg);
|
||||||
|
this.interactionText.setAlpha(1);
|
||||||
|
this.interactionTimer = 1500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
265
src/scenes/RenewalScene.ts
Normal file
265
src/scenes/RenewalScene.ts
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
/**
|
||||||
|
* RenewalScene — Great Renewal (Великое Обновление)
|
||||||
|
*
|
||||||
|
* GDD spec:
|
||||||
|
* After every 7th run, the world experiences a Great Renewal:
|
||||||
|
* - World generation fundamentally changes
|
||||||
|
* - New lore layer unlocks
|
||||||
|
* - Mycelium "matures" — opens new capabilities
|
||||||
|
* - Previous 7 runs leave traces in the next cycle
|
||||||
|
*
|
||||||
|
* Visual: A special transition between great cycles with
|
||||||
|
* pulsing Mycelium, cycle counter, and narrative theme reveal.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Phaser from 'phaser';
|
||||||
|
import type { MetaState, CycleTheme } from '../run/types';
|
||||||
|
import { CYCLE_THEME_NAMES_RU } from '../run/types';
|
||||||
|
import { GAME_WIDTH, GAME_HEIGHT } from '../config';
|
||||||
|
import narrativeData from '../data/cycle-narrative.json';
|
||||||
|
|
||||||
|
interface ThemeNarrative {
|
||||||
|
nameRu: string;
|
||||||
|
cradleQuoteRu: string;
|
||||||
|
loreFrag: { textRu: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RenewalScene extends Phaser.Scene {
|
||||||
|
private meta!: MetaState;
|
||||||
|
private previousCycle!: number;
|
||||||
|
private newCycle!: number;
|
||||||
|
private newTheme!: CycleTheme;
|
||||||
|
private elapsed = 0;
|
||||||
|
private readonly duration = 15000; // 15 seconds
|
||||||
|
private graphics!: Phaser.GameObjects.Graphics;
|
||||||
|
private particles: { x: number; y: number; vx: number; vy: number; r: number; alpha: number; color: number }[] = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super({ key: 'RenewalScene' });
|
||||||
|
}
|
||||||
|
|
||||||
|
init(data: { meta: MetaState; previousCycle: number }): void {
|
||||||
|
this.meta = data.meta;
|
||||||
|
this.previousCycle = data.previousCycle;
|
||||||
|
this.newCycle = this.meta.greatCycle.cycleNumber;
|
||||||
|
this.newTheme = this.meta.greatCycle.theme;
|
||||||
|
this.elapsed = 0;
|
||||||
|
this.particles = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
create(): void {
|
||||||
|
this.cameras.main.setBackgroundColor('#010204');
|
||||||
|
this.graphics = this.add.graphics();
|
||||||
|
|
||||||
|
const cx = GAME_WIDTH / 2;
|
||||||
|
const cy = GAME_HEIGHT / 2;
|
||||||
|
|
||||||
|
// Create Mycelium pulse particles (more than fractal — representing the network)
|
||||||
|
for (let i = 0; i < 150; i++) {
|
||||||
|
const angle = Math.random() * Math.PI * 2;
|
||||||
|
const dist = Math.random() * 350;
|
||||||
|
this.particles.push({
|
||||||
|
x: cx + Math.cos(angle) * dist,
|
||||||
|
y: cy + Math.sin(angle) * dist,
|
||||||
|
vx: (Math.random() - 0.5) * 30,
|
||||||
|
vy: (Math.random() - 0.5) * 30,
|
||||||
|
r: 1 + Math.random() * 3,
|
||||||
|
alpha: Math.random() * 0.4,
|
||||||
|
color: Phaser.Display.Color.HSLToColor(
|
||||||
|
0.35 + Math.random() * 0.15, // green-teal hues
|
||||||
|
0.5 + Math.random() * 0.3,
|
||||||
|
0.2 + Math.random() * 0.3,
|
||||||
|
).color,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Staged Text Reveals ─────────────────────────────────
|
||||||
|
|
||||||
|
// 1. "Великое Обновление" title (fades in at 0s)
|
||||||
|
const renewalMsg = this.pickRenewalMessage();
|
||||||
|
const titleText = this.add.text(cx, cy - 100, 'ВЕЛИКОЕ ОБНОВЛЕНИЕ', {
|
||||||
|
fontSize: '30px',
|
||||||
|
color: '#00ff88',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontStyle: 'bold',
|
||||||
|
}).setOrigin(0.5).setAlpha(0).setDepth(10);
|
||||||
|
|
||||||
|
this.tweens.add({
|
||||||
|
targets: titleText,
|
||||||
|
alpha: 1,
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Renewal flavor message (fades in at 2s)
|
||||||
|
const flavorText = this.add.text(cx, cy - 55, renewalMsg, {
|
||||||
|
fontSize: '13px',
|
||||||
|
color: '#448866',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
}).setOrigin(0.5).setAlpha(0).setDepth(10);
|
||||||
|
|
||||||
|
this.tweens.add({
|
||||||
|
targets: flavorText,
|
||||||
|
alpha: 0.8,
|
||||||
|
duration: 1500,
|
||||||
|
delay: 2000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Cycle counter "Цикл 1 → Цикл 2" (fades in at 4s)
|
||||||
|
const cycleText = this.add.text(cx, cy,
|
||||||
|
`Цикл ${this.previousCycle} → Цикл ${this.newCycle}`, {
|
||||||
|
fontSize: '22px',
|
||||||
|
color: '#ffffff',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontStyle: 'bold',
|
||||||
|
}).setOrigin(0.5).setAlpha(0).setDepth(10);
|
||||||
|
|
||||||
|
this.tweens.add({
|
||||||
|
targets: cycleText,
|
||||||
|
alpha: 1,
|
||||||
|
duration: 1500,
|
||||||
|
delay: 4000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. New theme name (fades in at 6s)
|
||||||
|
const themeName = CYCLE_THEME_NAMES_RU[this.newTheme];
|
||||||
|
const themeText = this.add.text(cx, cy + 40,
|
||||||
|
`Тема: ${themeName}`, {
|
||||||
|
fontSize: '18px',
|
||||||
|
color: '#44ddaa',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
}).setOrigin(0.5).setAlpha(0).setDepth(10);
|
||||||
|
|
||||||
|
this.tweens.add({
|
||||||
|
targets: themeText,
|
||||||
|
alpha: 1,
|
||||||
|
duration: 1500,
|
||||||
|
delay: 6000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Lore fragment from the new theme (fades in at 8s)
|
||||||
|
const narrative = this.getThemeNarrative(this.newTheme);
|
||||||
|
if (narrative && narrative.loreFrag.length > 0) {
|
||||||
|
const fragIdx = this.newCycle % narrative.loreFrag.length;
|
||||||
|
const loreText = this.add.text(cx, cy + 90,
|
||||||
|
`«${narrative.loreFrag[fragIdx].textRu}»`, {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#668866',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
align: 'center',
|
||||||
|
wordWrap: { width: GAME_WIDTH * 0.7 },
|
||||||
|
}).setOrigin(0.5).setAlpha(0).setDepth(10);
|
||||||
|
|
||||||
|
this.tweens.add({
|
||||||
|
targets: loreText,
|
||||||
|
alpha: 0.7,
|
||||||
|
duration: 2000,
|
||||||
|
delay: 8000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Mycelium maturation info (fades in at 10s)
|
||||||
|
const matPct = Math.round(this.meta.greatCycle.myceliumMaturation * 100);
|
||||||
|
const matText = this.add.text(cx, cy + 140,
|
||||||
|
`Мицелий созревает... (${matPct}%)`, {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#33aa66',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
}).setOrigin(0.5).setAlpha(0).setDepth(10);
|
||||||
|
|
||||||
|
this.tweens.add({
|
||||||
|
targets: matText,
|
||||||
|
alpha: 0.6,
|
||||||
|
duration: 1500,
|
||||||
|
delay: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Skip hint
|
||||||
|
const skipText = this.add.text(cx, GAME_HEIGHT - 25,
|
||||||
|
'[ click to skip ]', {
|
||||||
|
fontSize: '10px',
|
||||||
|
color: '#333333',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
}).setOrigin(0.5).setDepth(100);
|
||||||
|
|
||||||
|
// Click to skip after minimum 5 seconds
|
||||||
|
this.input.on('pointerdown', () => {
|
||||||
|
if (this.elapsed > 5000) {
|
||||||
|
this.transitionToCradle();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
update(_time: number, delta: number): void {
|
||||||
|
this.elapsed += delta;
|
||||||
|
|
||||||
|
// Animate Mycelium particles
|
||||||
|
this.graphics.clear();
|
||||||
|
const cx = GAME_WIDTH / 2;
|
||||||
|
const cy = GAME_HEIGHT / 2;
|
||||||
|
const t = this.elapsed / 1000;
|
||||||
|
|
||||||
|
for (const p of this.particles) {
|
||||||
|
// Spiral inward then outward (breathing effect)
|
||||||
|
const dx = cx - p.x;
|
||||||
|
const dy = cy - p.y;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||||
|
const breathe = Math.sin(t * 0.5) * 0.3;
|
||||||
|
|
||||||
|
p.vx += (dx / dist) * breathe;
|
||||||
|
p.vy += (dy / dist) * breathe;
|
||||||
|
|
||||||
|
p.x += p.vx * (delta / 1000);
|
||||||
|
p.y += p.vy * (delta / 1000);
|
||||||
|
|
||||||
|
// Damping
|
||||||
|
p.vx *= 0.98;
|
||||||
|
p.vy *= 0.98;
|
||||||
|
|
||||||
|
// Pulsing alpha
|
||||||
|
p.alpha = 0.1 + Math.sin(t * 1.5 + dist * 0.01) * 0.2;
|
||||||
|
|
||||||
|
this.graphics.fillStyle(p.color, Math.max(0.02, p.alpha));
|
||||||
|
this.graphics.fillCircle(p.x, p.y, p.r);
|
||||||
|
|
||||||
|
// Mycelium threads — connecting nearby particles
|
||||||
|
if (Math.random() < 0.01) {
|
||||||
|
const other = this.particles[Math.floor(Math.random() * this.particles.length)];
|
||||||
|
const threadDist = Math.hypot(p.x - other.x, p.y - other.y);
|
||||||
|
if (threadDist < 80) {
|
||||||
|
this.graphics.lineStyle(1, 0x33aa66, 0.08);
|
||||||
|
this.graphics.beginPath();
|
||||||
|
this.graphics.moveTo(p.x, p.y);
|
||||||
|
this.graphics.lineTo(other.x, other.y);
|
||||||
|
this.graphics.strokePath();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-transition after duration
|
||||||
|
if (this.elapsed >= this.duration) {
|
||||||
|
this.transitionToCradle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickRenewalMessage(): string {
|
||||||
|
const messages = narrativeData.renewalMessages;
|
||||||
|
const idx = this.previousCycle % messages.length;
|
||||||
|
return messages[idx].textRu;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getThemeNarrative(theme: CycleTheme): ThemeNarrative | null {
|
||||||
|
const themes = narrativeData.themes as Record<string, ThemeNarrative>;
|
||||||
|
return themes[theme] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private transitionToCradle(): void {
|
||||||
|
if ((this as unknown as { _transitioning?: boolean })._transitioning) return;
|
||||||
|
(this as unknown as { _transitioning?: boolean })._transitioning = true;
|
||||||
|
|
||||||
|
this.cameras.main.fadeOut(2000, 0, 0, 0);
|
||||||
|
this.cameras.main.once('camerafadeoutcomplete', () => {
|
||||||
|
this.scene.start('CradleScene', { meta: this.meta });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
226
src/scenes/UIScene.ts
Normal file
226
src/scenes/UIScene.ts
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
/**
|
||||||
|
* UIScene — HUD overlay on top of GameScene
|
||||||
|
*
|
||||||
|
* Renders health bar, quick slots, inventory weight.
|
||||||
|
* Reads shared game state from Phaser registry.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Phaser from 'phaser';
|
||||||
|
import { SLOT_COUNT } from '../player/quickslots';
|
||||||
|
import { ElementRegistry } from '../chemistry/elements';
|
||||||
|
import { CompoundRegistry } from '../chemistry/compounds';
|
||||||
|
|
||||||
|
const SLOT_SIZE = 44;
|
||||||
|
const SLOT_GAP = 4;
|
||||||
|
const HEALTH_BAR_WIDTH = 160;
|
||||||
|
const HEALTH_BAR_HEIGHT = 14;
|
||||||
|
|
||||||
|
/** Get color for a chemical item as number (for rendering) */
|
||||||
|
function getItemDisplayColor(itemId: string): number {
|
||||||
|
const el = ElementRegistry.getBySymbol(itemId);
|
||||||
|
if (el) return parseInt(el.color.replace('#', ''), 16);
|
||||||
|
const comp = CompoundRegistry.getById(itemId);
|
||||||
|
if (comp) return parseInt(comp.color.replace('#', ''), 16);
|
||||||
|
return 0xffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get display name for a chemical item */
|
||||||
|
function getItemDisplayName(itemId: string): string {
|
||||||
|
const el = ElementRegistry.getBySymbol(itemId);
|
||||||
|
if (el) return el.symbol;
|
||||||
|
const comp = CompoundRegistry.getById(itemId);
|
||||||
|
if (comp) return comp.formula;
|
||||||
|
return itemId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UIScene extends Phaser.Scene {
|
||||||
|
// Health
|
||||||
|
private healthBarBg!: Phaser.GameObjects.Rectangle;
|
||||||
|
private healthBarFill!: Phaser.GameObjects.Rectangle;
|
||||||
|
private healthText!: Phaser.GameObjects.Text;
|
||||||
|
|
||||||
|
// Quick slots
|
||||||
|
private slotBoxes: Phaser.GameObjects.Rectangle[] = [];
|
||||||
|
private slotTexts: Phaser.GameObjects.Text[] = [];
|
||||||
|
private slotNumTexts: Phaser.GameObjects.Text[] = [];
|
||||||
|
private slotCountTexts: Phaser.GameObjects.Text[] = [];
|
||||||
|
|
||||||
|
// Inventory
|
||||||
|
private invText!: Phaser.GameObjects.Text;
|
||||||
|
|
||||||
|
// Controls hint
|
||||||
|
private controlsText!: Phaser.GameObjects.Text;
|
||||||
|
|
||||||
|
// Cycle info (top-left, below health)
|
||||||
|
private cycleText!: Phaser.GameObjects.Text;
|
||||||
|
|
||||||
|
// Run phase (top-left, below cycle)
|
||||||
|
private phaseText!: Phaser.GameObjects.Text;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super({ key: 'UIScene' });
|
||||||
|
}
|
||||||
|
|
||||||
|
create(): void {
|
||||||
|
const w = this.cameras.main.width;
|
||||||
|
const h = this.cameras.main.height;
|
||||||
|
|
||||||
|
// === Health bar (top-left) ===
|
||||||
|
const hpX = 12;
|
||||||
|
const hpY = 12;
|
||||||
|
|
||||||
|
this.healthBarBg = this.add.rectangle(
|
||||||
|
hpX + HEALTH_BAR_WIDTH / 2, hpY + HEALTH_BAR_HEIGHT / 2,
|
||||||
|
HEALTH_BAR_WIDTH, HEALTH_BAR_HEIGHT, 0x330000,
|
||||||
|
);
|
||||||
|
this.healthBarBg.setStrokeStyle(1, 0x660000);
|
||||||
|
|
||||||
|
this.healthBarFill = this.add.rectangle(
|
||||||
|
hpX + HEALTH_BAR_WIDTH / 2, hpY + HEALTH_BAR_HEIGHT / 2,
|
||||||
|
HEALTH_BAR_WIDTH, HEALTH_BAR_HEIGHT, 0x00cc44,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.healthText = this.add.text(
|
||||||
|
hpX + HEALTH_BAR_WIDTH + 8, hpY, '', {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#00ff88',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// === Quick slots (bottom-center) ===
|
||||||
|
const totalW = SLOT_COUNT * SLOT_SIZE + (SLOT_COUNT - 1) * SLOT_GAP;
|
||||||
|
const startX = (w - totalW) / 2;
|
||||||
|
const startY = h - SLOT_SIZE - 12;
|
||||||
|
|
||||||
|
for (let i = 0; i < SLOT_COUNT; i++) {
|
||||||
|
const x = startX + i * (SLOT_SIZE + SLOT_GAP);
|
||||||
|
const cx = x + SLOT_SIZE / 2;
|
||||||
|
const cy = startY + SLOT_SIZE / 2;
|
||||||
|
|
||||||
|
// Slot background
|
||||||
|
const box = this.add.rectangle(cx, cy, SLOT_SIZE, SLOT_SIZE, 0x111111, 0.85);
|
||||||
|
box.setStrokeStyle(2, 0x444444);
|
||||||
|
this.slotBoxes.push(box);
|
||||||
|
|
||||||
|
// Slot number (top-left corner)
|
||||||
|
const numText = this.add.text(x + 3, startY + 2, `${i + 1}`, {
|
||||||
|
fontSize: '10px',
|
||||||
|
color: '#555555',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
});
|
||||||
|
this.slotNumTexts.push(numText);
|
||||||
|
|
||||||
|
// Item name (center)
|
||||||
|
const itemText = this.add.text(cx, cy - 2, '', {
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#ffffff',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontStyle: 'bold',
|
||||||
|
});
|
||||||
|
itemText.setOrigin(0.5);
|
||||||
|
this.slotTexts.push(itemText);
|
||||||
|
|
||||||
|
// Count (bottom-right)
|
||||||
|
const countText = this.add.text(x + SLOT_SIZE - 3, startY + SLOT_SIZE - 3, '', {
|
||||||
|
fontSize: '10px',
|
||||||
|
color: '#aaaaaa',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
});
|
||||||
|
countText.setOrigin(1, 1);
|
||||||
|
this.slotCountTexts.push(countText);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Inventory info (bottom-right) ===
|
||||||
|
this.invText = this.add.text(w - 12, h - 14, '', {
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#888888',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
backgroundColor: '#000000aa',
|
||||||
|
padding: { x: 4, y: 2 },
|
||||||
|
});
|
||||||
|
this.invText.setOrigin(1, 1);
|
||||||
|
|
||||||
|
// === Cycle info (top-left, below health bar) ===
|
||||||
|
this.cycleText = this.add.text(12, 32, '', {
|
||||||
|
fontSize: '10px',
|
||||||
|
color: '#446644',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Run phase (below cycle) ===
|
||||||
|
this.phaseText = this.add.text(12, 46, '', {
|
||||||
|
fontSize: '10px',
|
||||||
|
color: '#557755',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Controls hint (top-right) ===
|
||||||
|
this.controlsText = this.add.text(w - 12, 12, 'WASD move | E collect | F throw | 1-4 slots | scroll zoom', {
|
||||||
|
fontSize: '10px',
|
||||||
|
color: '#444444',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
});
|
||||||
|
this.controlsText.setOrigin(1, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(): void {
|
||||||
|
// === Read shared state from registry ===
|
||||||
|
const health = (this.registry.get('health') as number) ?? 100;
|
||||||
|
const healthMax = (this.registry.get('healthMax') as number) ?? 100;
|
||||||
|
const slots = (this.registry.get('quickSlots') as (string | null)[]) ?? [];
|
||||||
|
const activeSlot = (this.registry.get('activeSlot') as number) ?? 0;
|
||||||
|
const invWeight = (this.registry.get('invWeight') as number) ?? 0;
|
||||||
|
const invMaxWeight = (this.registry.get('invMaxWeight') as number) ?? 500;
|
||||||
|
const invSlots = (this.registry.get('invSlots') as number) ?? 0;
|
||||||
|
const invCounts = (this.registry.get('invCounts') as Map<string, number>) ?? new Map();
|
||||||
|
|
||||||
|
// === Update health bar ===
|
||||||
|
const ratio = healthMax > 0 ? health / healthMax : 0;
|
||||||
|
const fillWidth = HEALTH_BAR_WIDTH * ratio;
|
||||||
|
this.healthBarFill.width = fillWidth;
|
||||||
|
this.healthBarFill.x = this.healthBarBg.x - (HEALTH_BAR_WIDTH - fillWidth) / 2;
|
||||||
|
|
||||||
|
// Color: green → yellow → red
|
||||||
|
const hpColor = ratio > 0.5 ? 0x00cc44 : ratio > 0.25 ? 0xccaa00 : 0xcc2200;
|
||||||
|
this.healthBarFill.setFillStyle(hpColor);
|
||||||
|
this.healthText.setText(`${Math.round(health)}/${healthMax}`);
|
||||||
|
|
||||||
|
// === Update quick slots ===
|
||||||
|
for (let i = 0; i < SLOT_COUNT; i++) {
|
||||||
|
const itemId = i < slots.length ? slots[i] : null;
|
||||||
|
const isActive = i === activeSlot;
|
||||||
|
|
||||||
|
// Border color
|
||||||
|
this.slotBoxes[i].setStrokeStyle(2, isActive ? 0x00ff88 : 0x444444);
|
||||||
|
this.slotBoxes[i].setFillStyle(isActive ? 0x1a2a1a : 0x111111, 0.85);
|
||||||
|
|
||||||
|
if (itemId) {
|
||||||
|
const displayName = getItemDisplayName(itemId);
|
||||||
|
const displayColor = getItemDisplayColor(itemId);
|
||||||
|
this.slotTexts[i].setText(displayName);
|
||||||
|
this.slotTexts[i].setColor(`#${displayColor.toString(16).padStart(6, '0')}`);
|
||||||
|
|
||||||
|
const count = invCounts.get(itemId) ?? 0;
|
||||||
|
this.slotCountTexts[i].setText(count > 0 ? `${count}` : '0');
|
||||||
|
this.slotCountTexts[i].setColor(count > 0 ? '#aaaaaa' : '#660000');
|
||||||
|
} else {
|
||||||
|
this.slotTexts[i].setText('');
|
||||||
|
this.slotCountTexts[i].setText('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Update inventory ===
|
||||||
|
this.invText.setText(`${Math.round(invWeight)}/${invMaxWeight} AMU | ${invSlots} items`);
|
||||||
|
|
||||||
|
// === Update cycle info ===
|
||||||
|
const cycleNumber = (this.registry.get('cycleNumber') as number) ?? 1;
|
||||||
|
const runInCycle = (this.registry.get('runInCycle') as number) ?? 1;
|
||||||
|
const cycleTheme = (this.registry.get('cycleThemeRu') as string) ?? '';
|
||||||
|
this.cycleText.setText(`Цикл ${cycleNumber}: ${cycleTheme} | Ран ${runInCycle}/7`);
|
||||||
|
|
||||||
|
// === Update run phase ===
|
||||||
|
const runPhaseRu = (this.registry.get('runPhaseRu') as string) ?? '';
|
||||||
|
this.phaseText.setText(runPhaseRu);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/ui/screen-fix.ts
Normal file
31
src/ui/screen-fix.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type Phaser from 'phaser';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Position a scrollFactor(0) game object at fixed screen coordinates,
|
||||||
|
* compensating for camera zoom.
|
||||||
|
*
|
||||||
|
* Phaser's camera zoom scales around the viewport center, which displaces
|
||||||
|
* scrollFactor(0) objects:
|
||||||
|
* screenPos = (objPos − center) × zoom + center
|
||||||
|
*
|
||||||
|
* This function solves for the object position that maps to the desired
|
||||||
|
* screen pixel, and counter-scales the object so it appears at 1× size:
|
||||||
|
* objPos = (desiredScreen − center) / zoom + center
|
||||||
|
* scale = baseScale / zoom
|
||||||
|
*
|
||||||
|
* Call every frame for each UI element that uses setScrollFactor(0).
|
||||||
|
*/
|
||||||
|
export function fixToScreen(
|
||||||
|
obj: { x: number; y: number; setScale(x: number, y?: number): unknown },
|
||||||
|
screenX: number,
|
||||||
|
screenY: number,
|
||||||
|
camera: Phaser.Cameras.Scene2D.Camera,
|
||||||
|
baseScale = 1,
|
||||||
|
): void {
|
||||||
|
const zoom = camera.zoom;
|
||||||
|
const cx = camera.width * 0.5;
|
||||||
|
const cy = camera.height * 0.5;
|
||||||
|
obj.x = (screenX - cx) / zoom + cx;
|
||||||
|
obj.y = (screenY - cy) / zoom + cy;
|
||||||
|
obj.setScale(baseScale / zoom);
|
||||||
|
}
|
||||||
@@ -67,3 +67,29 @@ export function updateCamera(
|
|||||||
if (keys.up.isDown) camera.scrollY -= speed * dt;
|
if (keys.up.isDown) camera.scrollY -= speed * dt;
|
||||||
if (keys.down.isDown) camera.scrollY += speed * dt;
|
if (keys.down.isDown) camera.scrollY += speed * dt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up camera for player-follow mode.
|
||||||
|
* No WASD movement — camera follows the player entity.
|
||||||
|
* Zoom via mouse wheel, bounds clamped to world size.
|
||||||
|
*/
|
||||||
|
export function setupPlayerCamera(
|
||||||
|
scene: Phaser.Scene,
|
||||||
|
worldPixelWidth: number,
|
||||||
|
worldPixelHeight: number,
|
||||||
|
): void {
|
||||||
|
const camera = scene.cameras.main;
|
||||||
|
camera.setBounds(0, 0, worldPixelWidth, worldPixelHeight);
|
||||||
|
camera.setZoom(2); // closer view for gameplay
|
||||||
|
|
||||||
|
// Mouse wheel zoom (0.5x – 3x)
|
||||||
|
scene.input.on('wheel', (
|
||||||
|
_pointer: unknown,
|
||||||
|
_gameObjects: unknown,
|
||||||
|
_deltaX: number,
|
||||||
|
deltaY: number,
|
||||||
|
) => {
|
||||||
|
const newZoom = Phaser.Math.Clamp(camera.zoom - deltaY * 0.001, 0.5, 3);
|
||||||
|
camera.setZoom(newZoom);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,21 +47,27 @@ function determineTile(elevation: number, detail: number, biome: BiomeData): num
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Geyser overlay: on acid-shallow + very high detail noise
|
// Interactive overlay (geysers / steam vents / hollow stumps) on specific base tile + high detail noise
|
||||||
if (baseTileId === gen.geyserOnTile && detail > gen.geyserThreshold) {
|
if (baseTileId === gen.geyserOnTile && detail > gen.geyserThreshold) {
|
||||||
return findTileIdByName(biome, 'geyser');
|
return findInteractiveTileId(biome);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mineral overlay: on walkable ground + high detail noise
|
// Resource overlay (mineral veins / ore deposits / herb patches) on walkable ground + high detail noise
|
||||||
if (gen.mineralOnTiles.includes(baseTileId) && detail > gen.mineralThreshold) {
|
if (gen.mineralOnTiles.includes(baseTileId) && detail > gen.mineralThreshold) {
|
||||||
return findTileIdByName(biome, 'mineral-vein');
|
return findResourceTileId(biome);
|
||||||
}
|
}
|
||||||
|
|
||||||
return baseTileId;
|
return baseTileId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Find tile ID by name, falling back to 0 if not found */
|
/** Find the interactive tile (geyser/steam-vent/hollow-stump), falling back to 0 */
|
||||||
function findTileIdByName(biome: BiomeData, name: string): number {
|
function findInteractiveTileId(biome: BiomeData): number {
|
||||||
const tile = biome.tiles.find(t => t.name === name);
|
const tile = biome.tiles.find(t => t.interactive);
|
||||||
|
return tile ? tile.id : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find the resource tile (mineral-vein/ore-deposit/herb-patch), falling back to 0 */
|
||||||
|
function findResourceTileId(biome: BiomeData): number {
|
||||||
|
const tile = biome.tiles.find(t => t.resource);
|
||||||
return tile ? tile.id : 0;
|
return tile ? tile.id : 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
import type { TileData, TileGrid, WorldData } from './types';
|
import type { TileData, TileGrid, WorldData } from './types';
|
||||||
|
import { fixToScreen } from '../ui/screen-fix';
|
||||||
|
|
||||||
const MINIMAP_DEPTH = 100;
|
const MINIMAP_DEPTH = 100;
|
||||||
const VIEWPORT_DEPTH = 101;
|
const VIEWPORT_DEPTH = 101;
|
||||||
@@ -82,6 +83,10 @@ export class Minimap {
|
|||||||
const canvasW = w * scale;
|
const canvasW = w * scale;
|
||||||
const canvasH = h * scale;
|
const canvasH = h * scale;
|
||||||
|
|
||||||
|
// Remove old texture if re-entering scene (run cycle)
|
||||||
|
if (scene.textures.exists('minimap')) {
|
||||||
|
scene.textures.remove('minimap');
|
||||||
|
}
|
||||||
const canvasTexture = scene.textures.createCanvas('minimap', canvasW, canvasH);
|
const canvasTexture = scene.textures.createCanvas('minimap', canvasW, canvasH);
|
||||||
const ctx = canvasTexture.getContext();
|
const ctx = canvasTexture.getContext();
|
||||||
|
|
||||||
@@ -98,8 +103,17 @@ export class Minimap {
|
|||||||
|
|
||||||
/** Update the viewport indicator rectangle — call each frame */
|
/** Update the viewport indicator rectangle — call each frame */
|
||||||
update(camera: Phaser.Cameras.Scene2D.Camera): void {
|
update(camera: Phaser.Cameras.Scene2D.Camera): void {
|
||||||
|
// Compensate for camera zoom on all minimap elements.
|
||||||
|
// Graphics objects (border, viewport) use (0,0) as origin so their
|
||||||
|
// local draw coordinates map 1:1 to screen pixels after compensation.
|
||||||
|
fixToScreen(this.border, 0, 0, camera);
|
||||||
|
fixToScreen(this.viewport, 0, 0, camera);
|
||||||
|
|
||||||
|
// Image has origin(1,0): position = top-right corner
|
||||||
const screenW = camera.width;
|
const screenW = camera.width;
|
||||||
const padding = 10;
|
const padding = 10;
|
||||||
|
fixToScreen(this.image, screenW - padding, padding, camera);
|
||||||
|
|
||||||
const minimapW = this.mapWidth * this.minimapScale;
|
const minimapW = this.mapWidth * this.minimapScale;
|
||||||
const minimapX = screenW - padding - minimapW;
|
const minimapX = screenW - padding - minimapW;
|
||||||
const minimapY = padding;
|
const minimapY = padding;
|
||||||
|
|||||||
106
src/world/resources.ts
Normal file
106
src/world/resources.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Resource Spawner — creates ECS entities for harvestable world objects
|
||||||
|
*
|
||||||
|
* Scans the generated tile grid for mineral veins and geysers,
|
||||||
|
* creates an entity at each with a randomly assigned element.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { addEntity, addComponent } from 'bitecs';
|
||||||
|
import type { World } from '../ecs/world';
|
||||||
|
import { Position, Resource, SpriteRef } from '../ecs/components';
|
||||||
|
import type { TileGrid, BiomeData } from './types';
|
||||||
|
import {
|
||||||
|
pickResourceElement,
|
||||||
|
MINERAL_ELEMENTS,
|
||||||
|
GEYSER_ELEMENTS,
|
||||||
|
type ResourceInfo,
|
||||||
|
} from '../player/interaction';
|
||||||
|
|
||||||
|
/** Resource spawn configuration per tile type */
|
||||||
|
interface ResourceTileConfig {
|
||||||
|
tileId: number;
|
||||||
|
elements: readonly string[];
|
||||||
|
minQuantity: number;
|
||||||
|
maxQuantity: number;
|
||||||
|
interactRange: number;
|
||||||
|
spriteColor: number;
|
||||||
|
spriteRadius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawn resource entities for all resource tiles in the grid.
|
||||||
|
* @returns Map of entity ID → ResourceInfo for string data
|
||||||
|
*/
|
||||||
|
export function spawnResources(
|
||||||
|
world: World,
|
||||||
|
grid: TileGrid,
|
||||||
|
biome: BiomeData,
|
||||||
|
seed: number,
|
||||||
|
): Map<number, ResourceInfo> {
|
||||||
|
const resourceData = new Map<number, ResourceInfo>();
|
||||||
|
|
||||||
|
// Find tile IDs for resource types (generic: resource + interactive tiles)
|
||||||
|
const mineralTile = biome.tiles.find(t => t.resource);
|
||||||
|
const geyserTile = biome.tiles.find(t => t.interactive);
|
||||||
|
|
||||||
|
const configs: ResourceTileConfig[] = [];
|
||||||
|
|
||||||
|
if (mineralTile) {
|
||||||
|
configs.push({
|
||||||
|
tileId: mineralTile.id,
|
||||||
|
elements: MINERAL_ELEMENTS,
|
||||||
|
minQuantity: 3,
|
||||||
|
maxQuantity: 5,
|
||||||
|
interactRange: 40,
|
||||||
|
spriteColor: 0xffd700, // gold
|
||||||
|
spriteRadius: 4,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (geyserTile) {
|
||||||
|
configs.push({
|
||||||
|
tileId: geyserTile.id,
|
||||||
|
elements: GEYSER_ELEMENTS,
|
||||||
|
minQuantity: 2,
|
||||||
|
maxQuantity: 4,
|
||||||
|
interactRange: 48,
|
||||||
|
spriteColor: 0xff6600, // orange
|
||||||
|
spriteRadius: 5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tileSize = biome.tileSize;
|
||||||
|
|
||||||
|
for (let y = 0; y < grid.length; y++) {
|
||||||
|
for (let x = 0; x < grid[y].length; x++) {
|
||||||
|
const tileId = grid[y][x];
|
||||||
|
const config = configs.find(c => c.tileId === tileId);
|
||||||
|
if (!config) continue;
|
||||||
|
|
||||||
|
// Pick element deterministically
|
||||||
|
const itemId = pickResourceElement(x, y, seed, config.elements);
|
||||||
|
|
||||||
|
// Quantity from deterministic hash
|
||||||
|
const qHash = ((x * 48611) ^ (y * 29423) ^ (seed * 61379)) >>> 0;
|
||||||
|
const range = config.maxQuantity - config.minQuantity + 1;
|
||||||
|
const quantity = config.minQuantity + (qHash % range);
|
||||||
|
|
||||||
|
// Create entity at tile center
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Position);
|
||||||
|
addComponent(world, eid, Resource);
|
||||||
|
addComponent(world, eid, SpriteRef);
|
||||||
|
|
||||||
|
Position.x[eid] = x * tileSize + tileSize / 2;
|
||||||
|
Position.y[eid] = y * tileSize + tileSize / 2;
|
||||||
|
Resource.quantity[eid] = quantity;
|
||||||
|
Resource.interactRange[eid] = config.interactRange;
|
||||||
|
SpriteRef.color[eid] = config.spriteColor;
|
||||||
|
SpriteRef.radius[eid] = config.spriteRadius;
|
||||||
|
|
||||||
|
resourceData.set(eid, { itemId, tileX: x, tileY: y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resourceData;
|
||||||
|
}
|
||||||
@@ -15,7 +15,12 @@ export function createWorldTilemap(
|
|||||||
const { grid, biome } = worldData;
|
const { grid, biome } = worldData;
|
||||||
const textureKey = `tileset-${biome.id}`;
|
const textureKey = `tileset-${biome.id}`;
|
||||||
|
|
||||||
// 1. Generate tileset texture
|
// 1. Remove old texture if re-entering scene (run cycle)
|
||||||
|
if (scene.textures.exists(textureKey)) {
|
||||||
|
scene.textures.remove(textureKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Generate tileset texture
|
||||||
createTilesetTexture(scene, biome.tiles, biome.tileSize, textureKey);
|
createTilesetTexture(scene, biome.tiles, biome.tileSize, textureKey);
|
||||||
|
|
||||||
// 2. Create tilemap from grid
|
// 2. Create tilemap from grid
|
||||||
|
|||||||
194
src/world/traces.ts
Normal file
194
src/world/traces.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* World Trace Placement — places ruins and markers from past runs
|
||||||
|
*
|
||||||
|
* GDD (Law of Trace): "Nothing disappears without a trace;
|
||||||
|
* each cycle leaves an imprint in the next."
|
||||||
|
*
|
||||||
|
* When generating a new world, we check for traces from past runs
|
||||||
|
* in the same biome (from the current and previous great cycle).
|
||||||
|
* Death sites become ruin markers; discovery sites become faded experiment traces.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { addEntity, addComponent } from 'bitecs';
|
||||||
|
import type { World } from '../ecs/world';
|
||||||
|
import { Position, SpriteRef, WorldTrace, TraceType } from '../ecs/components';
|
||||||
|
import type { BiomeData } from './types';
|
||||||
|
import type { RunTrace, GreatCycleState } from '../run/types';
|
||||||
|
import { getTracesForBiome, getDeathTraces } from '../run/cycle';
|
||||||
|
|
||||||
|
/** String data for a world trace entity (not stored in ECS — numeric only there) */
|
||||||
|
export interface WorldTraceInfo {
|
||||||
|
/** Entity ID */
|
||||||
|
eid: number;
|
||||||
|
/** Type of trace */
|
||||||
|
traceType: 'death_site' | 'discovery_site';
|
||||||
|
/** Run that left this trace */
|
||||||
|
sourceRunId: number;
|
||||||
|
/** School used in that run */
|
||||||
|
schoolId: string;
|
||||||
|
/** Key elements discovered (for discovery sites) */
|
||||||
|
keyElements: string[];
|
||||||
|
/** Tile position */
|
||||||
|
tileX: number;
|
||||||
|
tileY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Visual config for trace markers */
|
||||||
|
const TRACE_CONFIG = {
|
||||||
|
death_site: {
|
||||||
|
color: 0x884444, // dark red — blood/decay
|
||||||
|
radius: 6,
|
||||||
|
interactRange: 40,
|
||||||
|
glowColor: 0xaa3333, // red glow
|
||||||
|
},
|
||||||
|
discovery_site: {
|
||||||
|
color: 0x446688, // blue-gray — knowledge
|
||||||
|
radius: 5,
|
||||||
|
interactRange: 36,
|
||||||
|
glowColor: 0x4488aa, // blue glow
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawn world trace entities from past run traces.
|
||||||
|
*
|
||||||
|
* For each trace from the current/previous cycle in this biome:
|
||||||
|
* - Death sites: place a ruin marker at the death position
|
||||||
|
* - Remaining traces: place discovery markers at a position derived from
|
||||||
|
* the run's world seed (since we don't store exact discovery locations)
|
||||||
|
*
|
||||||
|
* @returns Array of WorldTraceInfo for display/interaction
|
||||||
|
*/
|
||||||
|
export function spawnWorldTraces(
|
||||||
|
world: World,
|
||||||
|
cycleState: GreatCycleState,
|
||||||
|
biomeId: string,
|
||||||
|
biome: BiomeData,
|
||||||
|
): WorldTraceInfo[] {
|
||||||
|
const traces = getTracesForBiome(cycleState, biomeId);
|
||||||
|
if (traces.length === 0) return [];
|
||||||
|
|
||||||
|
const result: WorldTraceInfo[] = [];
|
||||||
|
const tileSize = biome.tileSize;
|
||||||
|
const mapW = biome.mapWidth;
|
||||||
|
const mapH = biome.mapHeight;
|
||||||
|
|
||||||
|
// Place death site markers
|
||||||
|
const deathTraces = getDeathTraces(traces);
|
||||||
|
for (const trace of deathTraces) {
|
||||||
|
const pos = trace.deathPosition;
|
||||||
|
if (!pos) continue;
|
||||||
|
|
||||||
|
// Clamp to map bounds
|
||||||
|
const tx = Math.max(0, Math.min(pos.tileX, mapW - 1));
|
||||||
|
const ty = Math.max(0, Math.min(pos.tileY, mapH - 1));
|
||||||
|
|
||||||
|
const eid = createTraceEntity(
|
||||||
|
world, tx, ty, tileSize,
|
||||||
|
TraceType.DeathSite, trace.runId,
|
||||||
|
TRACE_CONFIG.death_site,
|
||||||
|
);
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
eid,
|
||||||
|
traceType: 'death_site',
|
||||||
|
sourceRunId: trace.runId,
|
||||||
|
schoolId: trace.schoolId,
|
||||||
|
keyElements: trace.keyElements,
|
||||||
|
tileX: tx,
|
||||||
|
tileY: ty,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Place discovery markers for traces with significant discoveries
|
||||||
|
// (Only for traces without death sites, to avoid double-marking)
|
||||||
|
const deathRunIds = new Set(deathTraces.map(t => t.runId));
|
||||||
|
const discoveryTraces = traces.filter(
|
||||||
|
t => !deathRunIds.has(t.runId) && t.discoveryCount >= 3,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const trace of discoveryTraces) {
|
||||||
|
// Derive a position from the run's seed (deterministic but varied)
|
||||||
|
const tx = deriveTracePosition(trace.worldSeed, 0, mapW);
|
||||||
|
const ty = deriveTracePosition(trace.worldSeed, 1, mapH);
|
||||||
|
|
||||||
|
const eid = createTraceEntity(
|
||||||
|
world, tx, ty, tileSize,
|
||||||
|
TraceType.DiscoverySite, trace.runId,
|
||||||
|
TRACE_CONFIG.discovery_site,
|
||||||
|
);
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
eid,
|
||||||
|
traceType: 'discovery_site',
|
||||||
|
sourceRunId: trace.runId,
|
||||||
|
schoolId: trace.schoolId,
|
||||||
|
keyElements: trace.keyElements,
|
||||||
|
tileX: tx,
|
||||||
|
tileY: ty,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a single trace entity with position, sprite, and trace component */
|
||||||
|
function createTraceEntity(
|
||||||
|
world: World,
|
||||||
|
tileX: number,
|
||||||
|
tileY: number,
|
||||||
|
tileSize: number,
|
||||||
|
traceType: number,
|
||||||
|
sourceRunId: number,
|
||||||
|
config: { color: number; radius: number; interactRange: number },
|
||||||
|
): number {
|
||||||
|
const eid = addEntity(world);
|
||||||
|
|
||||||
|
addComponent(world, eid, Position);
|
||||||
|
Position.x[eid] = tileX * tileSize + tileSize / 2;
|
||||||
|
Position.y[eid] = tileY * tileSize + tileSize / 2;
|
||||||
|
|
||||||
|
addComponent(world, eid, SpriteRef);
|
||||||
|
SpriteRef.color[eid] = config.color;
|
||||||
|
SpriteRef.radius[eid] = config.radius;
|
||||||
|
|
||||||
|
addComponent(world, eid, WorldTrace);
|
||||||
|
WorldTrace.traceType[eid] = traceType;
|
||||||
|
WorldTrace.sourceRunId[eid] = sourceRunId;
|
||||||
|
WorldTrace.glowPhase[eid] = Math.random() * Math.PI * 2;
|
||||||
|
WorldTrace.interactRange[eid] = config.interactRange;
|
||||||
|
|
||||||
|
return eid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a deterministic tile position from a seed.
|
||||||
|
* Ensures traces from the same run appear in consistent locations.
|
||||||
|
*/
|
||||||
|
function deriveTracePosition(seed: number, axis: number, mapSize: number): number {
|
||||||
|
// Mulberry32-style hash
|
||||||
|
let t = (seed + axis * 0x6d2b79f5 + 0x9e3779b9) | 0;
|
||||||
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||||
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||||
|
const normalized = ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
|
||||||
|
// Keep away from edges (10% margin)
|
||||||
|
const margin = Math.floor(mapSize * 0.1);
|
||||||
|
return margin + Math.floor(normalized * (mapSize - 2 * margin));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update trace glow animation (pulsing effect).
|
||||||
|
* Called in the game update loop.
|
||||||
|
*/
|
||||||
|
export function updateTraceGlow(
|
||||||
|
traceInfos: WorldTraceInfo[],
|
||||||
|
delta: number,
|
||||||
|
): void {
|
||||||
|
const pulseSpeed = 1.2; // radians per second
|
||||||
|
const deltaSec = delta / 1000;
|
||||||
|
|
||||||
|
for (const info of traceInfos) {
|
||||||
|
WorldTrace.glowPhase[info.eid] += pulseSpeed * deltaSec;
|
||||||
|
}
|
||||||
|
}
|
||||||
758
tests/boss.test.ts
Normal file
758
tests/boss.test.ts
Normal file
@@ -0,0 +1,758 @@
|
|||||||
|
/**
|
||||||
|
* Boss System Tests — Phase 8: First Archont (Ouroboros)
|
||||||
|
*
|
||||||
|
* Tests cover:
|
||||||
|
* - Boss state creation and initialization
|
||||||
|
* - Phase cycling (Coil → Spray → Lash → Digest → repeat)
|
||||||
|
* - Phase speedup per cycle
|
||||||
|
* - Damage mechanics (vulnerability windows, armor)
|
||||||
|
* - Chemical damage (NaOH during Spray)
|
||||||
|
* - Catalyst poison (Hg stacks, regen/armor reduction)
|
||||||
|
* - Victory detection and method determination
|
||||||
|
* - Arena generation (circular layout, features)
|
||||||
|
* - Reward calculation (spores, lore)
|
||||||
|
* - Boss entity factory (ECS)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { createWorld, addEntity, addComponent, query } from 'bitecs';
|
||||||
|
import type { World } from '../src/ecs/world';
|
||||||
|
import { Position, Health, SpriteRef, Boss } from '../src/ecs/components';
|
||||||
|
|
||||||
|
// Boss system imports
|
||||||
|
import {
|
||||||
|
createBossState,
|
||||||
|
updateBossPhase,
|
||||||
|
getEffectiveArmor,
|
||||||
|
getEffectiveRegen,
|
||||||
|
isVulnerable,
|
||||||
|
getEffectivePhaseDuration,
|
||||||
|
} from '../src/boss/ai';
|
||||||
|
import {
|
||||||
|
applyBossDamage,
|
||||||
|
isBossDefeated,
|
||||||
|
} from '../src/boss/victory';
|
||||||
|
import {
|
||||||
|
generateArena,
|
||||||
|
buildArenaWalkableSet,
|
||||||
|
} from '../src/boss/arena';
|
||||||
|
import {
|
||||||
|
calculateBossReward,
|
||||||
|
applyBossReward,
|
||||||
|
} from '../src/boss/reward';
|
||||||
|
import { createBossEntity } from '../src/boss/factory';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
import {
|
||||||
|
BossPhase,
|
||||||
|
VictoryMethod,
|
||||||
|
type BossData,
|
||||||
|
type BossState,
|
||||||
|
} from '../src/boss/types';
|
||||||
|
import type { BiomeData } from '../src/world/types';
|
||||||
|
import { createMetaState } from '../src/run/meta';
|
||||||
|
|
||||||
|
// Load boss data
|
||||||
|
import bossDataArray from '../src/data/bosses.json';
|
||||||
|
import biomeDataArray from '../src/data/biomes.json';
|
||||||
|
|
||||||
|
const ouroboros = bossDataArray[0] as BossData;
|
||||||
|
const biome = biomeDataArray[0] as BiomeData;
|
||||||
|
|
||||||
|
// ─── Boss State Creation ─────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Boss State Creation', () => {
|
||||||
|
it('creates initial boss state from data', () => {
|
||||||
|
const state = createBossState(ouroboros);
|
||||||
|
expect(state.bossId).toBe('ouroboros');
|
||||||
|
expect(state.health).toBe(300);
|
||||||
|
expect(state.maxHealth).toBe(300);
|
||||||
|
expect(state.currentPhase).toBe(BossPhase.Coil);
|
||||||
|
expect(state.cycleCount).toBe(0);
|
||||||
|
expect(state.catalystStacks).toBe(0);
|
||||||
|
expect(state.defeated).toBe(false);
|
||||||
|
expect(state.victoryMethod).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts with phase timer set to first phase duration', () => {
|
||||||
|
const state = createBossState(ouroboros);
|
||||||
|
expect(state.phaseTimer).toBe(ouroboros.phaseDurations[0]); // 5000ms
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts with zero damage counters', () => {
|
||||||
|
const state = createBossState(ouroboros);
|
||||||
|
expect(state.totalDamageDealt).toBe(0);
|
||||||
|
expect(state.chemicalDamageDealt).toBe(0);
|
||||||
|
expect(state.directDamageDealt).toBe(0);
|
||||||
|
expect(state.catalystDamageDealt).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Phase Cycling ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Boss Phase Cycling', () => {
|
||||||
|
let state: BossState;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state = createBossState(ouroboros);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays in current phase while timer has not expired', () => {
|
||||||
|
updateBossPhase(state, ouroboros, 1000);
|
||||||
|
expect(state.currentPhase).toBe(BossPhase.Coil);
|
||||||
|
expect(state.phaseTimer).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('transitions from Coil to Spray when timer expires', () => {
|
||||||
|
updateBossPhase(state, ouroboros, 5000); // Full Coil duration
|
||||||
|
expect(state.currentPhase).toBe(BossPhase.Spray);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('transitions through full cycle: Coil → Spray → Lash → Digest', () => {
|
||||||
|
// Coil (5s) → Spray
|
||||||
|
updateBossPhase(state, ouroboros, 5000);
|
||||||
|
expect(state.currentPhase).toBe(BossPhase.Spray);
|
||||||
|
|
||||||
|
// Spray (8s) → Lash
|
||||||
|
updateBossPhase(state, ouroboros, 8000);
|
||||||
|
expect(state.currentPhase).toBe(BossPhase.Lash);
|
||||||
|
|
||||||
|
// Lash (6s) → Digest
|
||||||
|
updateBossPhase(state, ouroboros, 6000);
|
||||||
|
expect(state.currentPhase).toBe(BossPhase.Digest);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('completes a full cycle and increments cycleCount', () => {
|
||||||
|
// Full cycle: 5000 + 8000 + 6000 + 4000 = 23000ms
|
||||||
|
const events = [];
|
||||||
|
events.push(...updateBossPhase(state, ouroboros, 5000)); // Coil → Spray
|
||||||
|
events.push(...updateBossPhase(state, ouroboros, 8000)); // Spray → Lash
|
||||||
|
events.push(...updateBossPhase(state, ouroboros, 6000)); // Lash → Digest
|
||||||
|
events.push(...updateBossPhase(state, ouroboros, 4000)); // Digest → Coil (cycle 1)
|
||||||
|
|
||||||
|
expect(state.currentPhase).toBe(BossPhase.Coil);
|
||||||
|
expect(state.cycleCount).toBe(1);
|
||||||
|
|
||||||
|
const cycleEvent = events.find(e => e.type === 'cycle_complete');
|
||||||
|
expect(cycleEvent).toBeDefined();
|
||||||
|
expect(cycleEvent?.cycleCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits phase_change events', () => {
|
||||||
|
const events = updateBossPhase(state, ouroboros, 5000); // Coil → Spray
|
||||||
|
const phaseChange = events.find(e => e.type === 'phase_change');
|
||||||
|
expect(phaseChange).toBeDefined();
|
||||||
|
expect(phaseChange?.phase).toBe(BossPhase.Spray);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits boss_attack during Spray phase', () => {
|
||||||
|
updateBossPhase(state, ouroboros, 5000); // → Spray
|
||||||
|
const events = updateBossPhase(state, ouroboros, 100); // Tick in Spray
|
||||||
|
const attack = events.find(e => e.type === 'boss_attack');
|
||||||
|
expect(attack).toBeDefined();
|
||||||
|
expect(attack?.phase).toBe(BossPhase.Spray);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits boss_attack during Lash phase', () => {
|
||||||
|
updateBossPhase(state, ouroboros, 5000 + 8000); // → Lash
|
||||||
|
const events = updateBossPhase(state, ouroboros, 100); // Tick in Lash
|
||||||
|
const attack = events.find(e => e.type === 'boss_attack');
|
||||||
|
expect(attack).toBeDefined();
|
||||||
|
expect(attack?.phase).toBe(BossPhase.Lash);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not emit boss_attack during Coil or Digest', () => {
|
||||||
|
// During Coil
|
||||||
|
const coilEvents = updateBossPhase(state, ouroboros, 100);
|
||||||
|
expect(coilEvents.filter(e => e.type === 'boss_attack')).toHaveLength(0);
|
||||||
|
|
||||||
|
// Advance to Digest
|
||||||
|
updateBossPhase(state, ouroboros, 4900 + 8000 + 6000); // Coil → Spray → Lash → Digest
|
||||||
|
const digestEvents = updateBossPhase(state, ouroboros, 100);
|
||||||
|
expect(digestEvents.filter(e => e.type === 'boss_attack')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not update when defeated', () => {
|
||||||
|
state.defeated = true;
|
||||||
|
const events = updateBossPhase(state, ouroboros, 10000);
|
||||||
|
expect(events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Phase Speedup ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Boss Phase Speedup', () => {
|
||||||
|
it('returns base duration for cycle 0', () => {
|
||||||
|
const duration = getEffectivePhaseDuration(
|
||||||
|
{ cycleCount: 0 } as BossState,
|
||||||
|
ouroboros,
|
||||||
|
BossPhase.Coil,
|
||||||
|
);
|
||||||
|
expect(duration).toBe(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reduces duration by speedup factor per cycle', () => {
|
||||||
|
// Cycle 1: 5000 * 0.9 = 4500
|
||||||
|
const duration = getEffectivePhaseDuration(
|
||||||
|
{ cycleCount: 1 } as BossState,
|
||||||
|
ouroboros,
|
||||||
|
BossPhase.Coil,
|
||||||
|
);
|
||||||
|
expect(duration).toBeCloseTo(4500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps speedup at maxCycles', () => {
|
||||||
|
// Cycle 5 (maxCycles): 5000 * 0.9^5 = 2952.45
|
||||||
|
const duration5 = getEffectivePhaseDuration(
|
||||||
|
{ cycleCount: 5 } as BossState,
|
||||||
|
ouroboros,
|
||||||
|
BossPhase.Coil,
|
||||||
|
);
|
||||||
|
// Cycle 10 (should cap at 5): same
|
||||||
|
const duration10 = getEffectivePhaseDuration(
|
||||||
|
{ cycleCount: 10 } as BossState,
|
||||||
|
ouroboros,
|
||||||
|
BossPhase.Coil,
|
||||||
|
);
|
||||||
|
expect(duration10).toBeCloseTo(duration5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies speedup to all phases', () => {
|
||||||
|
const state = { cycleCount: 2 } as BossState;
|
||||||
|
const coil = getEffectivePhaseDuration(state, ouroboros, BossPhase.Coil);
|
||||||
|
const spray = getEffectivePhaseDuration(state, ouroboros, BossPhase.Spray);
|
||||||
|
const lash = getEffectivePhaseDuration(state, ouroboros, BossPhase.Lash);
|
||||||
|
const digest = getEffectivePhaseDuration(state, ouroboros, BossPhase.Digest);
|
||||||
|
|
||||||
|
expect(coil).toBeCloseTo(5000 * 0.81); // 0.9^2
|
||||||
|
expect(spray).toBeCloseTo(8000 * 0.81);
|
||||||
|
expect(lash).toBeCloseTo(6000 * 0.81);
|
||||||
|
expect(digest).toBeCloseTo(4000 * 0.81);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Armor & Regeneration ────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Boss Armor and Regeneration', () => {
|
||||||
|
let state: BossState;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state = createBossState(ouroboros);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has full armor during non-vulnerable phases', () => {
|
||||||
|
state.currentPhase = BossPhase.Coil;
|
||||||
|
expect(getEffectiveArmor(state, ouroboros)).toBe(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has reduced armor during vulnerable phases', () => {
|
||||||
|
state.currentPhase = BossPhase.Digest;
|
||||||
|
expect(getEffectiveArmor(state, ouroboros)).toBe(0.1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has full regen rate with 0 catalyst stacks', () => {
|
||||||
|
expect(getEffectiveRegen(state, ouroboros)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reduces regen per catalyst stack', () => {
|
||||||
|
state.catalystStacks = 1;
|
||||||
|
expect(getEffectiveRegen(state, ouroboros)).toBe(3); // 5 - 1*2 = 3
|
||||||
|
});
|
||||||
|
|
||||||
|
it('regen does not go below 0', () => {
|
||||||
|
state.catalystStacks = 3;
|
||||||
|
expect(getEffectiveRegen(state, ouroboros)).toBe(0); // 5 - 3*2 = -1 → 0
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reduces armor per catalyst stack', () => {
|
||||||
|
state.catalystStacks = 2;
|
||||||
|
state.currentPhase = BossPhase.Coil;
|
||||||
|
expect(getEffectiveArmor(state, ouroboros)).toBe(0.3); // 0.5 - 2*0.1
|
||||||
|
});
|
||||||
|
|
||||||
|
it('armor does not go below 0', () => {
|
||||||
|
state.catalystStacks = 10; // Way over max stacks
|
||||||
|
state.currentPhase = BossPhase.Digest;
|
||||||
|
expect(getEffectiveArmor(state, ouroboros)).toBe(0); // 0.1 - 10*0.1 → 0
|
||||||
|
});
|
||||||
|
|
||||||
|
it('identifies vulnerable phases correctly', () => {
|
||||||
|
state.currentPhase = BossPhase.Digest;
|
||||||
|
expect(isVulnerable(state, ouroboros)).toBe(true);
|
||||||
|
|
||||||
|
state.currentPhase = BossPhase.Coil;
|
||||||
|
expect(isVulnerable(state, ouroboros)).toBe(false);
|
||||||
|
|
||||||
|
state.currentPhase = BossPhase.Spray;
|
||||||
|
expect(isVulnerable(state, ouroboros)).toBe(false);
|
||||||
|
|
||||||
|
state.currentPhase = BossPhase.Lash;
|
||||||
|
expect(isVulnerable(state, ouroboros)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies regeneration over time', () => {
|
||||||
|
state.health = 250; // Damaged
|
||||||
|
updateBossPhase(state, ouroboros, 2000); // 2 seconds → 10 HP regen
|
||||||
|
expect(state.health).toBeCloseTo(260);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not regenerate past max health', () => {
|
||||||
|
state.health = 298;
|
||||||
|
updateBossPhase(state, ouroboros, 2000); // Would regen 10, capped at 300
|
||||||
|
expect(state.health).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('regeneration is reduced by catalyst stacks', () => {
|
||||||
|
state.health = 250;
|
||||||
|
state.catalystStacks = 2; // regen = 5 - 2*2 = 1 HP/s
|
||||||
|
updateBossPhase(state, ouroboros, 2000); // 2 seconds → 2 HP regen
|
||||||
|
expect(state.health).toBeCloseTo(252);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Damage: Direct (Victory Path 2) ────────────────────────────
|
||||||
|
|
||||||
|
describe('Boss Damage — Direct', () => {
|
||||||
|
let state: BossState;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state = createBossState(ouroboros);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deals full damage during Digest (vulnerable) phase', () => {
|
||||||
|
state.currentPhase = BossPhase.Digest;
|
||||||
|
const result = applyBossDamage(state, ouroboros, 'Fe');
|
||||||
|
// 15 * (1 - 0.1) = 13.5 → 14 rounded
|
||||||
|
expect(result.damageDealt).toBe(14);
|
||||||
|
expect(result.damageType).toBe(VictoryMethod.Direct);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deals reduced damage during non-vulnerable phases', () => {
|
||||||
|
state.currentPhase = BossPhase.Coil;
|
||||||
|
const result = applyBossDamage(state, ouroboros, 'Fe');
|
||||||
|
// 15 * (1 - 0.5) = 7.5 → 8 rounded
|
||||||
|
expect(result.damageDealt).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks direct damage dealt', () => {
|
||||||
|
state.currentPhase = BossPhase.Digest;
|
||||||
|
applyBossDamage(state, ouroboros, 'Fe');
|
||||||
|
expect(state.directDamageDealt).toBeGreaterThan(0);
|
||||||
|
expect(state.totalDamageDealt).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not deal damage to defeated boss', () => {
|
||||||
|
state.defeated = true;
|
||||||
|
const result = applyBossDamage(state, ouroboros, 'Fe');
|
||||||
|
expect(result.damageDealt).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Damage: Chemical (Victory Path 1) ──────────────────────────
|
||||||
|
|
||||||
|
describe('Boss Damage — Chemical (NaOH)', () => {
|
||||||
|
let state: BossState;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state = createBossState(ouroboros);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deals multiplied damage with NaOH during Spray phase', () => {
|
||||||
|
state.currentPhase = BossPhase.Spray;
|
||||||
|
const result = applyBossDamage(state, ouroboros, 'NaOH');
|
||||||
|
// 15 * 3.0 * (1 - 0.5) = 22.5 → 23 rounded
|
||||||
|
expect(result.damageDealt).toBe(23);
|
||||||
|
expect(result.damageType).toBe(VictoryMethod.Chemical);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats NaOH as normal projectile outside effective phases', () => {
|
||||||
|
state.currentPhase = BossPhase.Coil; // Not a chemical-effective phase
|
||||||
|
const result = applyBossDamage(state, ouroboros, 'NaOH');
|
||||||
|
// Normal reduced damage: 15 * (1 - 0.5) = 8
|
||||||
|
expect(result.damageDealt).toBe(8);
|
||||||
|
expect(result.damageType).toBe(VictoryMethod.Direct);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks chemical damage separately', () => {
|
||||||
|
state.currentPhase = BossPhase.Spray;
|
||||||
|
applyBossDamage(state, ouroboros, 'NaOH');
|
||||||
|
expect(state.chemicalDamageDealt).toBeGreaterThan(0);
|
||||||
|
expect(state.directDamageDealt).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Damage: Catalytic Poison (Victory Path 3) ──────────────────
|
||||||
|
|
||||||
|
describe('Boss Damage — Catalytic Poison (Hg)', () => {
|
||||||
|
let state: BossState;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state = createBossState(ouroboros);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies catalyst stack on first Hg hit', () => {
|
||||||
|
const result = applyBossDamage(state, ouroboros, 'Hg');
|
||||||
|
expect(result.catalystApplied).toBe(true);
|
||||||
|
expect(state.catalystStacks).toBe(1);
|
||||||
|
expect(result.damageType).toBe(VictoryMethod.Catalytic);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps at maxCatalystStacks', () => {
|
||||||
|
state.catalystStacks = 3; // Already at max
|
||||||
|
const result = applyBossDamage(state, ouroboros, 'Hg');
|
||||||
|
expect(result.catalystApplied).toBe(false);
|
||||||
|
expect(state.catalystStacks).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deals damage on every Hg application', () => {
|
||||||
|
const result = applyBossDamage(state, ouroboros, 'Hg');
|
||||||
|
expect(result.damageDealt).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks catalyst damage separately', () => {
|
||||||
|
applyBossDamage(state, ouroboros, 'Hg');
|
||||||
|
expect(state.catalystDamageDealt).toBeGreaterThan(0);
|
||||||
|
expect(state.chemicalDamageDealt).toBe(0);
|
||||||
|
expect(state.directDamageDealt).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Hg works in any phase (not phase-dependent)', () => {
|
||||||
|
for (const phase of [BossPhase.Coil, BossPhase.Spray, BossPhase.Lash, BossPhase.Digest]) {
|
||||||
|
const s = createBossState(ouroboros);
|
||||||
|
s.currentPhase = phase;
|
||||||
|
const result = applyBossDamage(s, ouroboros, 'Hg');
|
||||||
|
expect(result.catalystApplied).toBe(true);
|
||||||
|
expect(result.damageType).toBe(VictoryMethod.Catalytic);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Victory Detection ───────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Boss Victory Detection', () => {
|
||||||
|
let state: BossState;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state = createBossState(ouroboros);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects killing blow', () => {
|
||||||
|
state.health = 5;
|
||||||
|
state.currentPhase = BossPhase.Digest;
|
||||||
|
const result = applyBossDamage(state, ouroboros, 'Fe');
|
||||||
|
expect(result.killingBlow).toBe(true);
|
||||||
|
expect(state.defeated).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets victoryMethod based on killing blow type', () => {
|
||||||
|
state.health = 5;
|
||||||
|
state.currentPhase = BossPhase.Spray;
|
||||||
|
applyBossDamage(state, ouroboros, 'NaOH');
|
||||||
|
expect(state.victoryMethod).toBe(VictoryMethod.Chemical);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets Catalytic victory on Hg killing blow', () => {
|
||||||
|
state.health = 3;
|
||||||
|
applyBossDamage(state, ouroboros, 'Hg');
|
||||||
|
expect(state.victoryMethod).toBe(VictoryMethod.Catalytic);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isBossDefeated returns true when defeated', () => {
|
||||||
|
expect(isBossDefeated(state)).toBe(false);
|
||||||
|
state.health = 1;
|
||||||
|
state.currentPhase = BossPhase.Digest;
|
||||||
|
applyBossDamage(state, ouroboros, 'Fe');
|
||||||
|
expect(isBossDefeated(state)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('health does not go below 0', () => {
|
||||||
|
state.health = 1;
|
||||||
|
state.currentPhase = BossPhase.Digest;
|
||||||
|
applyBossDamage(state, ouroboros, 'Fe');
|
||||||
|
expect(state.health).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Arena Generation ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Arena Generation', () => {
|
||||||
|
it('generates a grid of correct size', () => {
|
||||||
|
const arena = generateArena(ouroboros, biome);
|
||||||
|
const diameter = ouroboros.arenaRadius * 2 + 1;
|
||||||
|
expect(arena.width).toBe(diameter);
|
||||||
|
expect(arena.height).toBe(diameter);
|
||||||
|
expect(arena.grid.length).toBe(diameter);
|
||||||
|
expect(arena.grid[0].length).toBe(diameter);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has walkable ground in the center', () => {
|
||||||
|
const arena = generateArena(ouroboros, biome);
|
||||||
|
const center = ouroboros.arenaRadius;
|
||||||
|
// Center tile should be walkable ground (0)
|
||||||
|
expect(arena.grid[center][center]).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has crystal wall border', () => {
|
||||||
|
const arena = generateArena(ouroboros, biome);
|
||||||
|
const r = ouroboros.arenaRadius;
|
||||||
|
// Top edge should be bedrock (7) or crystal (4)
|
||||||
|
// Very top-left corner is outside circle → bedrock
|
||||||
|
expect(arena.grid[0][0]).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has boss spawn at center', () => {
|
||||||
|
const arena = generateArena(ouroboros, biome);
|
||||||
|
const center = ouroboros.arenaRadius;
|
||||||
|
const tileSize = biome.tileSize;
|
||||||
|
expect(arena.bossSpawnX).toBe(center * tileSize + tileSize / 2);
|
||||||
|
expect(arena.bossSpawnY).toBe(center * tileSize + tileSize / 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has player spawn south of center', () => {
|
||||||
|
const arena = generateArena(ouroboros, biome);
|
||||||
|
expect(arena.playerSpawnY).toBeGreaterThan(arena.bossSpawnY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places 4 resource deposits', () => {
|
||||||
|
const arena = generateArena(ouroboros, biome);
|
||||||
|
expect(arena.resourcePositions).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resource positions are within arena bounds', () => {
|
||||||
|
const arena = generateArena(ouroboros, biome);
|
||||||
|
const maxPixel = arena.width * biome.tileSize;
|
||||||
|
for (const pos of arena.resourcePositions) {
|
||||||
|
expect(pos.x).toBeGreaterThan(0);
|
||||||
|
expect(pos.x).toBeLessThan(maxPixel);
|
||||||
|
expect(pos.y).toBeGreaterThan(0);
|
||||||
|
expect(pos.y).toBeLessThan(maxPixel);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has acid tiles in the arena', () => {
|
||||||
|
const arena = generateArena(ouroboros, biome);
|
||||||
|
let acidCount = 0;
|
||||||
|
for (const row of arena.grid) {
|
||||||
|
for (const tile of row) {
|
||||||
|
if (tile === 2) acidCount++; // ACID_SHALLOW
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(acidCount).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buildArenaWalkableSet includes expected tiles', () => {
|
||||||
|
const walkable = buildArenaWalkableSet();
|
||||||
|
expect(walkable.has(0)).toBe(true); // GROUND
|
||||||
|
expect(walkable.has(1)).toBe(true); // SCORCHED_EARTH
|
||||||
|
expect(walkable.has(6)).toBe(true); // MINERAL_VEIN
|
||||||
|
expect(walkable.has(7)).toBe(false); // BEDROCK (not walkable)
|
||||||
|
expect(walkable.has(4)).toBe(false); // CRYSTAL (not walkable)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Reward System ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Boss Reward System', () => {
|
||||||
|
it('calculates base reward for direct victory', () => {
|
||||||
|
const state = createBossState(ouroboros);
|
||||||
|
state.defeated = true;
|
||||||
|
state.victoryMethod = VictoryMethod.Direct;
|
||||||
|
const reward = calculateBossReward(state, ouroboros);
|
||||||
|
expect(reward.spores).toBe(100); // Base
|
||||||
|
expect(reward.loreId).toBe('ouroboros');
|
||||||
|
expect(reward.victoryMethod).toBe(VictoryMethod.Direct);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives 50% bonus for chemical victory', () => {
|
||||||
|
const state = createBossState(ouroboros);
|
||||||
|
state.defeated = true;
|
||||||
|
state.victoryMethod = VictoryMethod.Chemical;
|
||||||
|
const reward = calculateBossReward(state, ouroboros);
|
||||||
|
expect(reward.spores).toBe(150); // 100 * 1.5
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives 100% bonus for catalytic victory', () => {
|
||||||
|
const state = createBossState(ouroboros);
|
||||||
|
state.defeated = true;
|
||||||
|
state.victoryMethod = VictoryMethod.Catalytic;
|
||||||
|
const reward = calculateBossReward(state, ouroboros);
|
||||||
|
expect(reward.spores).toBe(200); // 100 * 2.0
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no spores if boss not defeated', () => {
|
||||||
|
const state = createBossState(ouroboros);
|
||||||
|
const reward = calculateBossReward(state, ouroboros);
|
||||||
|
expect(reward.spores).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes lore text in reward', () => {
|
||||||
|
const state = createBossState(ouroboros);
|
||||||
|
state.defeated = true;
|
||||||
|
state.victoryMethod = VictoryMethod.Direct;
|
||||||
|
const reward = calculateBossReward(state, ouroboros);
|
||||||
|
expect(reward.loreText.length).toBeGreaterThan(0);
|
||||||
|
expect(reward.loreTextRu.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applyBossReward adds spores to meta', () => {
|
||||||
|
const meta = createMetaState();
|
||||||
|
const initialSpores = meta.spores;
|
||||||
|
const state = createBossState(ouroboros);
|
||||||
|
state.defeated = true;
|
||||||
|
state.victoryMethod = VictoryMethod.Direct;
|
||||||
|
const reward = calculateBossReward(state, ouroboros);
|
||||||
|
applyBossReward(meta, reward, 1);
|
||||||
|
expect(meta.spores).toBe(initialSpores + reward.spores);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applyBossReward adds lore to codex', () => {
|
||||||
|
const meta = createMetaState();
|
||||||
|
const state = createBossState(ouroboros);
|
||||||
|
state.defeated = true;
|
||||||
|
state.victoryMethod = VictoryMethod.Direct;
|
||||||
|
const reward = calculateBossReward(state, ouroboros);
|
||||||
|
applyBossReward(meta, reward, 1);
|
||||||
|
const bossEntry = meta.codex.find(e => e.id === 'ouroboros');
|
||||||
|
expect(bossEntry).toBeDefined();
|
||||||
|
expect(bossEntry?.type).toBe('boss');
|
||||||
|
expect(bossEntry?.discoveredOnRun).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not add duplicate codex entries', () => {
|
||||||
|
const meta = createMetaState();
|
||||||
|
const state = createBossState(ouroboros);
|
||||||
|
state.defeated = true;
|
||||||
|
state.victoryMethod = VictoryMethod.Direct;
|
||||||
|
const reward = calculateBossReward(state, ouroboros);
|
||||||
|
applyBossReward(meta, reward, 1);
|
||||||
|
applyBossReward(meta, reward, 2);
|
||||||
|
const bossEntries = meta.codex.filter(e => e.id === 'ouroboros');
|
||||||
|
expect(bossEntries).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Boss Entity Factory ─────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Boss Entity Factory', () => {
|
||||||
|
let world: World;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = createWorld();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates an entity with Position component', () => {
|
||||||
|
const eid = createBossEntity(world, ouroboros, 400, 300);
|
||||||
|
expect(Position.x[eid]).toBe(400);
|
||||||
|
expect(Position.y[eid]).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates an entity with Health component', () => {
|
||||||
|
const eid = createBossEntity(world, ouroboros, 400, 300);
|
||||||
|
expect(Health.current[eid]).toBe(300);
|
||||||
|
expect(Health.max[eid]).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates an entity with SpriteRef component', () => {
|
||||||
|
const eid = createBossEntity(world, ouroboros, 400, 300);
|
||||||
|
expect(SpriteRef.color[eid]).toBe(0xcc44ff);
|
||||||
|
expect(SpriteRef.radius[eid]).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates an entity with Boss component', () => {
|
||||||
|
const eid = createBossEntity(world, ouroboros, 400, 300);
|
||||||
|
expect(Boss.dataIndex[eid]).toBe(0);
|
||||||
|
expect(Boss.phase[eid]).toBe(0); // BossPhase.Coil
|
||||||
|
expect(Boss.cycleCount[eid]).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('entity is queryable by Boss component', () => {
|
||||||
|
const eid = createBossEntity(world, ouroboros, 400, 300);
|
||||||
|
const bosses = query(world, [Boss]);
|
||||||
|
expect(bosses).toContain(eid);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Integration: Full Boss Fight ────────────────────────────────
|
||||||
|
|
||||||
|
describe('Boss Fight Integration', () => {
|
||||||
|
it('can defeat boss with direct damage during Digest windows', () => {
|
||||||
|
const state = createBossState(ouroboros);
|
||||||
|
let totalCycles = 0;
|
||||||
|
|
||||||
|
// Simulate multiple cycles of hitting during Digest
|
||||||
|
// In a 4s Digest window, player can throw ~10 projectiles
|
||||||
|
while (!state.defeated && totalCycles < 30) {
|
||||||
|
// Advance through Coil → Spray → Lash → Digest
|
||||||
|
updateBossPhase(state, ouroboros, 5000); // Coil (+ regen)
|
||||||
|
updateBossPhase(state, ouroboros, 8000); // Spray (+ regen)
|
||||||
|
updateBossPhase(state, ouroboros, 6000); // Lash (+ regen)
|
||||||
|
|
||||||
|
// Now in Digest — rapid-fire attacks (10 per 4s window is realistic)
|
||||||
|
for (let i = 0; i < 10 && !state.defeated; i++) {
|
||||||
|
state.currentPhase = BossPhase.Digest; // Ensure in Digest
|
||||||
|
applyBossDamage(state, ouroboros, 'Fe');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateBossPhase(state, ouroboros, 4000); // Complete Digest
|
||||||
|
totalCycles++;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(state.defeated).toBe(true);
|
||||||
|
expect(state.victoryMethod).toBe(VictoryMethod.Direct);
|
||||||
|
// Direct path is slow but viable (boss regens 5 HP/s between windows)
|
||||||
|
expect(totalCycles).toBeLessThan(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can defeat boss faster with NaOH during Spray', () => {
|
||||||
|
const state = createBossState(ouroboros);
|
||||||
|
let hits = 0;
|
||||||
|
|
||||||
|
// Advance to Spray phase and keep hitting with NaOH
|
||||||
|
state.currentPhase = BossPhase.Spray;
|
||||||
|
|
||||||
|
while (!state.defeated && hits < 50) {
|
||||||
|
applyBossDamage(state, ouroboros, 'NaOH');
|
||||||
|
hits++;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(state.defeated).toBe(true);
|
||||||
|
expect(state.victoryMethod).toBe(VictoryMethod.Chemical);
|
||||||
|
expect(hits).toBeLessThan(20); // NaOH should be efficient
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Hg makes boss progressively weaker', () => {
|
||||||
|
const state = createBossState(ouroboros);
|
||||||
|
|
||||||
|
// Apply 3 Hg stacks
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
applyBossDamage(state, ouroboros, 'Hg');
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(state.catalystStacks).toBe(3);
|
||||||
|
// Regen should be 0 (5 - 3*2 = -1 → 0)
|
||||||
|
expect(getEffectiveRegen(state, ouroboros)).toBe(0);
|
||||||
|
// Armor should be reduced: 0.5 - 3*0.1 = 0.2
|
||||||
|
expect(getEffectiveArmor(state, ouroboros)).toBeCloseTo(0.2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catalyst poison + direct damage is a viable strategy', () => {
|
||||||
|
const state = createBossState(ouroboros);
|
||||||
|
|
||||||
|
// Apply 3 Hg stacks to maximize weakness
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
applyBossDamage(state, ouroboros, 'Hg');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now kill with direct damage during Digest
|
||||||
|
state.currentPhase = BossPhase.Digest;
|
||||||
|
let hits = 0;
|
||||||
|
while (!state.defeated && hits < 50) {
|
||||||
|
applyBossDamage(state, ouroboros, 'Fe');
|
||||||
|
hits++;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(state.defeated).toBe(true);
|
||||||
|
// Victory method should be Direct (killing blow was Fe)
|
||||||
|
expect(state.victoryMethod).toBe(VictoryMethod.Direct);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,8 +8,8 @@ import { ReactionEngine } from '../src/chemistry/engine';
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
describe('ElementRegistry', () => {
|
describe('ElementRegistry', () => {
|
||||||
it('should load all 20 elements', () => {
|
it('should load all 40 elements', () => {
|
||||||
expect(ElementRegistry.count()).toBe(20);
|
expect(ElementRegistry.count()).toBe(40);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should look up elements by symbol', () => {
|
it('should look up elements by symbol', () => {
|
||||||
@@ -59,6 +59,39 @@ describe('ElementRegistry', () => {
|
|||||||
expect(ElementRegistry.isElement('NaCl')).toBe(false);
|
expect(ElementRegistry.isElement('NaCl')).toBe(false);
|
||||||
expect(ElementRegistry.isElement('H2O')).toBe(false);
|
expect(ElementRegistry.isElement('H2O')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should have all 20 new Phase 9 elements', () => {
|
||||||
|
const newSymbols = ['Li', 'B', 'F', 'Ne', 'Ar', 'Ti', 'Cr', 'Mn', 'Co', 'Ni', 'As', 'Br', 'Ag', 'I', 'Ba', 'W', 'Pt', 'Pb', 'Bi', 'U'];
|
||||||
|
for (const sym of newSymbols) {
|
||||||
|
expect(ElementRegistry.has(sym), `Element ${sym} not found`).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have correct data for new elements (real periodic table)', () => {
|
||||||
|
const li = ElementRegistry.getBySymbol('Li')!;
|
||||||
|
expect(li.atomicNumber).toBe(3);
|
||||||
|
expect(li.category).toBe('alkali-metal');
|
||||||
|
|
||||||
|
const f = ElementRegistry.getBySymbol('F')!;
|
||||||
|
expect(f.atomicNumber).toBe(9);
|
||||||
|
expect(f.category).toBe('halogen');
|
||||||
|
expect(f.state).toBe('gas');
|
||||||
|
expect(f.electronegativity).toBeCloseTo(3.98, 1); // Most electronegative
|
||||||
|
|
||||||
|
const br = ElementRegistry.getBySymbol('Br')!;
|
||||||
|
expect(br.state).toBe('liquid'); // Only liquid non-metal at room temp
|
||||||
|
|
||||||
|
const w = ElementRegistry.getBySymbol('W')!;
|
||||||
|
expect(w.atomicNumber).toBe(74);
|
||||||
|
expect(w.name).toBe('Tungsten');
|
||||||
|
|
||||||
|
const u = ElementRegistry.getBySymbol('U')!;
|
||||||
|
expect(u.atomicNumber).toBe(92);
|
||||||
|
expect(u.category).toBe('actinide');
|
||||||
|
|
||||||
|
const pt = ElementRegistry.getBySymbol('Pt')!;
|
||||||
|
expect(pt.category).toBe('transition-metal');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -106,6 +139,32 @@ describe('CompoundRegistry', () => {
|
|||||||
expect(CompoundRegistry.isCompound('H2O')).toBe(true);
|
expect(CompoundRegistry.isCompound('H2O')).toBe(true);
|
||||||
expect(CompoundRegistry.isCompound('Na')).toBe(false);
|
expect(CompoundRegistry.isCompound('Na')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should load all 64 compounds', () => {
|
||||||
|
expect(CompoundRegistry.count()).toBe(64);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have new Phase 9 compounds', () => {
|
||||||
|
const newIds = ['NH3', 'HF', 'HBr', 'TiO2', 'MnO2', 'As2O3', 'H2SO4', 'HNO3', 'CuO', 'FeCl3', 'CaCl2', 'NH4Cl', 'C6H12O6', 'CH3COOH'];
|
||||||
|
for (const id of newIds) {
|
||||||
|
expect(CompoundRegistry.has(id), `Compound ${id} not found`).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly flag new dangerous compounds', () => {
|
||||||
|
const hf = CompoundRegistry.getById('HF')!;
|
||||||
|
expect(hf.properties.acidic).toBe(true);
|
||||||
|
expect(hf.properties.corrosive).toBe(true);
|
||||||
|
expect(hf.properties.toxic).toBe(true);
|
||||||
|
|
||||||
|
const h2so4 = CompoundRegistry.getById('H2SO4')!;
|
||||||
|
expect(h2so4.properties.acidic).toBe(true);
|
||||||
|
expect(h2so4.properties.oxidizer).toBe(true);
|
||||||
|
|
||||||
|
const as2o3 = CompoundRegistry.getById('As2O3')!;
|
||||||
|
expect(as2o3.properties.toxic).toBe(true);
|
||||||
|
expect(as2o3.name).toContain('Arsenic');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -204,6 +263,104 @@ describe('ReactionEngine — success', () => {
|
|||||||
expect(result.reaction!.energyChange).toBeGreaterThan(0); // Endothermic
|
expect(result.reaction!.energyChange).toBeGreaterThan(0); // Endothermic
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should produce HF from H + F', () => {
|
||||||
|
const result = ReactionEngine.react([
|
||||||
|
{ id: 'H', count: 1 },
|
||||||
|
{ id: 'F', count: 1 },
|
||||||
|
]);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.products).toEqual([{ id: 'HF', count: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should produce NH3 via Haber process (N + 3H with Fe catalyst + heat)', () => {
|
||||||
|
const result = ReactionEngine.react(
|
||||||
|
[
|
||||||
|
{ id: 'N', count: 1 },
|
||||||
|
{ id: 'H', count: 3 },
|
||||||
|
],
|
||||||
|
{ minTemp: 500, catalyst: 'Fe' },
|
||||||
|
);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.products).toEqual([{ id: 'NH3', count: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should produce TiO2 from Ti + 2O with extreme heat', () => {
|
||||||
|
const result = ReactionEngine.react(
|
||||||
|
[
|
||||||
|
{ id: 'Ti', count: 1 },
|
||||||
|
{ id: 'O', count: 2 },
|
||||||
|
],
|
||||||
|
{ minTemp: 1000 },
|
||||||
|
);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.products).toEqual([{ id: 'TiO2', count: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should produce tungsten via WO3 + Al redox', () => {
|
||||||
|
const result = ReactionEngine.react(
|
||||||
|
[
|
||||||
|
{ id: 'WO3', count: 1 },
|
||||||
|
{ id: 'Al', count: 2 },
|
||||||
|
],
|
||||||
|
{ minTemp: 1000 },
|
||||||
|
);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.products).toContainEqual({ id: 'W', count: 1 });
|
||||||
|
expect(result.products).toContainEqual({ id: 'Al2O3', count: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should neutralize HF with NaOH (acid-base)', () => {
|
||||||
|
const result = ReactionEngine.react([
|
||||||
|
{ id: 'NaOH', count: 1 },
|
||||||
|
{ id: 'HF', count: 1 },
|
||||||
|
]);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.products).toContainEqual({ id: 'NaF', count: 1 });
|
||||||
|
expect(result.products).toContainEqual({ id: 'H2O', count: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should dissolve Zn in HCl (single-replacement)', () => {
|
||||||
|
const result = ReactionEngine.react([
|
||||||
|
{ id: 'Zn', count: 1 },
|
||||||
|
{ id: 'HCl', count: 2 },
|
||||||
|
]);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.products).toContainEqual({ id: 'ZnCl2', count: 1 });
|
||||||
|
expect(result.products).toContainEqual({ id: 'H', count: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should displace Cu with Fe from CuCl2', () => {
|
||||||
|
const result = ReactionEngine.react([
|
||||||
|
{ id: 'Fe', count: 1 },
|
||||||
|
{ id: 'CuCl2', count: 1 },
|
||||||
|
]);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.products).toContainEqual({ id: 'Cu', count: 1 });
|
||||||
|
expect(result.products).toContainEqual({ id: 'FeCl2', count: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ferment glucose into ethanol + CO2', () => {
|
||||||
|
const result = ReactionEngine.react([
|
||||||
|
{ id: 'C6H12O6', count: 1 },
|
||||||
|
]);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.products).toContainEqual({ id: 'C2H5OH', count: 2 });
|
||||||
|
expect(result.products).toContainEqual({ id: 'CO2', count: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should produce H2SO4 via Contact process (Pt catalyst)', () => {
|
||||||
|
const result = ReactionEngine.react(
|
||||||
|
[
|
||||||
|
{ id: 'S', count: 1 },
|
||||||
|
{ id: 'O', count: 3 },
|
||||||
|
{ id: 'H', count: 2 },
|
||||||
|
],
|
||||||
|
{ catalyst: 'Pt' },
|
||||||
|
);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.products).toEqual([{ id: 'H2SO4', count: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
it('reactant order should not matter (key is sorted)', () => {
|
it('reactant order should not matter (key is sorted)', () => {
|
||||||
const r1 = ReactionEngine.react([
|
const r1 = ReactionEngine.react([
|
||||||
{ id: 'Cl', count: 1 },
|
{ id: 'Cl', count: 1 },
|
||||||
@@ -234,6 +391,16 @@ describe('ReactionEngine — failures', () => {
|
|||||||
expect(result.failureReasonRu).toContain('благородный газ');
|
expect(result.failureReasonRu).toContain('благородный газ');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should reject reactions with new noble gases (Ne, Ar)', () => {
|
||||||
|
const ne = ReactionEngine.react([{ id: 'Ne', count: 1 }, { id: 'F', count: 1 }]);
|
||||||
|
expect(ne.success).toBe(false);
|
||||||
|
expect(ne.failureReason).toContain('noble gas');
|
||||||
|
|
||||||
|
const ar = ReactionEngine.react([{ id: 'Ar', count: 1 }, { id: 'Cl', count: 1 }]);
|
||||||
|
expect(ar.success).toBe(false);
|
||||||
|
expect(ar.failureReason).toContain('noble gas');
|
||||||
|
});
|
||||||
|
|
||||||
it('should reject gold reactions with explanation', () => {
|
it('should reject gold reactions with explanation', () => {
|
||||||
const result = ReactionEngine.react([
|
const result = ReactionEngine.react([
|
||||||
{ id: 'Au', count: 1 },
|
{ id: 'Au', count: 1 },
|
||||||
@@ -309,8 +476,8 @@ describe('ReactionEngine — failures', () => {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
describe('ReactionEngine — metadata', () => {
|
describe('ReactionEngine — metadata', () => {
|
||||||
it('should have 30+ registered reactions', () => {
|
it('should have 100+ registered reactions', () => {
|
||||||
expect(ReactionEngine.count()).toBeGreaterThanOrEqual(30);
|
expect(ReactionEngine.count()).toBeGreaterThanOrEqual(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should look up reactions by id', () => {
|
it('should look up reactions by id', () => {
|
||||||
|
|||||||
189
tests/crafting.test.ts
Normal file
189
tests/crafting.test.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* Crafting System Tests — Phase 4.4
|
||||||
|
*
|
||||||
|
* Tests: crafting from inventory, reagent consumption,
|
||||||
|
* product addition, failure reasons, condition checks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { Inventory } from '../src/player/inventory';
|
||||||
|
import { craftFromInventory, type CraftInput } from '../src/player/crafting';
|
||||||
|
|
||||||
|
describe('craftFromInventory — success', () => {
|
||||||
|
it('crafts NaCl from Na + Cl', () => {
|
||||||
|
const inv = new Inventory();
|
||||||
|
inv.addItem('Na', 3);
|
||||||
|
inv.addItem('Cl', 2);
|
||||||
|
|
||||||
|
const inputs: CraftInput[] = [
|
||||||
|
{ id: 'Na', count: 1 },
|
||||||
|
{ id: 'Cl', count: 1 },
|
||||||
|
];
|
||||||
|
const result = craftFromInventory(inv, inputs);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.products).toContainEqual({ id: 'NaCl', count: 1 });
|
||||||
|
// Reagents consumed
|
||||||
|
expect(inv.getCount('Na')).toBe(2);
|
||||||
|
expect(inv.getCount('Cl')).toBe(1);
|
||||||
|
// Product added
|
||||||
|
expect(inv.getCount('NaCl')).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns reaction data on success', () => {
|
||||||
|
const inv = new Inventory();
|
||||||
|
inv.addItem('Na', 1);
|
||||||
|
inv.addItem('Cl', 1);
|
||||||
|
|
||||||
|
const result = craftFromInventory(inv, [
|
||||||
|
{ id: 'Na', count: 1 },
|
||||||
|
{ id: 'Cl', count: 1 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.reaction).toBeDefined();
|
||||||
|
expect(result.reaction!.id).toBe('synth_nacl');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports consumed reactants', () => {
|
||||||
|
const inv = new Inventory();
|
||||||
|
inv.addItem('Na', 5);
|
||||||
|
inv.addItem('Cl', 5);
|
||||||
|
|
||||||
|
const inputs: CraftInput[] = [
|
||||||
|
{ id: 'Na', count: 1 },
|
||||||
|
{ id: 'Cl', count: 1 },
|
||||||
|
];
|
||||||
|
const result = craftFromInventory(inv, inputs);
|
||||||
|
|
||||||
|
expect(result.consumedReactants).toEqual(inputs);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('craftFromInventory — insufficient materials', () => {
|
||||||
|
it('fails when missing reagent', () => {
|
||||||
|
const inv = new Inventory();
|
||||||
|
inv.addItem('Na', 1);
|
||||||
|
// No Cl in inventory
|
||||||
|
|
||||||
|
const result = craftFromInventory(inv, [
|
||||||
|
{ id: 'Na', count: 1 },
|
||||||
|
{ id: 'Cl', count: 1 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.failureReason).toContain('Cl');
|
||||||
|
// Na not consumed
|
||||||
|
expect(inv.getCount('Na')).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails when not enough of a reagent', () => {
|
||||||
|
const inv = new Inventory();
|
||||||
|
inv.addItem('Na', 1);
|
||||||
|
inv.addItem('Cl', 1);
|
||||||
|
|
||||||
|
const result = craftFromInventory(inv, [
|
||||||
|
{ id: 'Na', count: 5 }, // need 5, have 1
|
||||||
|
{ id: 'Cl', count: 1 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.failureReason).toContain('Na');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('craftFromInventory — unknown reaction', () => {
|
||||||
|
it('fails with educational reason for no known reaction', () => {
|
||||||
|
const inv = new Inventory(5000); // large capacity for heavy elements
|
||||||
|
inv.addItem('Fe', 5);
|
||||||
|
inv.addItem('Au', 5);
|
||||||
|
|
||||||
|
const result = craftFromInventory(inv, [
|
||||||
|
{ id: 'Fe', count: 1 },
|
||||||
|
{ id: 'Au', count: 1 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.failureReason).toBeDefined();
|
||||||
|
expect(result.failureReason!.length).toBeGreaterThan(10); // educational reason
|
||||||
|
// Reagents not consumed
|
||||||
|
expect(inv.getCount('Fe')).toBe(5);
|
||||||
|
expect(inv.getCount('Au')).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('explains noble gas inertness', () => {
|
||||||
|
const inv = new Inventory();
|
||||||
|
inv.addItem('He', 5);
|
||||||
|
inv.addItem('O', 5);
|
||||||
|
|
||||||
|
const result = craftFromInventory(inv, [
|
||||||
|
{ id: 'He', count: 1 },
|
||||||
|
{ id: 'O', count: 1 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.failureReason).toContain('noble gas');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('craftFromInventory — conditions', () => {
|
||||||
|
it('fails when temperature requirement not met', () => {
|
||||||
|
const inv = new Inventory();
|
||||||
|
inv.addItem('Fe', 5);
|
||||||
|
inv.addItem('S', 5);
|
||||||
|
|
||||||
|
// Fe + S → FeS requires minTemp: 500
|
||||||
|
const result = craftFromInventory(inv, [
|
||||||
|
{ id: 'Fe', count: 1 },
|
||||||
|
{ id: 'S', count: 1 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.failureReason).toContain('temperature');
|
||||||
|
// Reagents not consumed
|
||||||
|
expect(inv.getCount('Fe')).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('succeeds when conditions are met', () => {
|
||||||
|
const inv = new Inventory();
|
||||||
|
inv.addItem('Fe', 5);
|
||||||
|
inv.addItem('S', 5);
|
||||||
|
|
||||||
|
const result = craftFromInventory(inv, [
|
||||||
|
{ id: 'Fe', count: 1 },
|
||||||
|
{ id: 'S', count: 1 },
|
||||||
|
], { minTemp: 500 });
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.products).toContainEqual({ id: 'FeS', count: 1 });
|
||||||
|
expect(inv.getCount('Fe')).toBe(4);
|
||||||
|
expect(inv.getCount('S')).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('craftFromInventory — edge cases', () => {
|
||||||
|
it('handles empty reactants list', () => {
|
||||||
|
const inv = new Inventory();
|
||||||
|
inv.addItem('Na', 5);
|
||||||
|
|
||||||
|
const result = craftFromInventory(inv, []);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles inventory full for products', () => {
|
||||||
|
const inv = new Inventory(100, 2); // Only 2 slots
|
||||||
|
inv.addItem('Na', 1);
|
||||||
|
inv.addItem('Cl', 1);
|
||||||
|
|
||||||
|
// After crafting: Na consumed (slot freed), Cl consumed (slot freed), NaCl added
|
||||||
|
// This should work since slots are freed before products are added
|
||||||
|
const result = craftFromInventory(inv, [
|
||||||
|
{ id: 'Na', count: 1 },
|
||||||
|
{ id: 'Cl', count: 1 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(inv.getCount('NaCl')).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
306
tests/creature-interaction.test.ts
Normal file
306
tests/creature-interaction.test.ts
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { createWorld, addEntity, addComponent, query } from 'bitecs';
|
||||||
|
import type { World } from '../src/ecs/world';
|
||||||
|
import {
|
||||||
|
Position,
|
||||||
|
Health,
|
||||||
|
Creature,
|
||||||
|
AI,
|
||||||
|
Metabolism,
|
||||||
|
LifeCycle,
|
||||||
|
Projectile,
|
||||||
|
PlayerTag,
|
||||||
|
SpriteRef,
|
||||||
|
Velocity,
|
||||||
|
} from '../src/ecs/components';
|
||||||
|
import { SpeciesId, AIState, LifeStage } from '../src/creatures/types';
|
||||||
|
import type { SpeciesData, CreatureInfo } from '../src/creatures/types';
|
||||||
|
import { createCreatureEntity } from '../src/creatures/factory';
|
||||||
|
import {
|
||||||
|
creatureProjectileSystem,
|
||||||
|
getObservableCreatures,
|
||||||
|
creatureAttackPlayerSystem,
|
||||||
|
} from '../src/creatures/interaction';
|
||||||
|
import type { ProjectileData } from '../src/player/projectile';
|
||||||
|
import speciesDataArray from '../src/data/creatures.json';
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const allSpecies = speciesDataArray as SpeciesData[];
|
||||||
|
|
||||||
|
function getSpecies(id: string): SpeciesData {
|
||||||
|
const s = allSpecies.find(s => s.id === id);
|
||||||
|
if (!s) throw new Error(`Species not found: ${id}`);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSpeciesLookup(): Map<number, SpeciesData> {
|
||||||
|
const map = new Map<number, SpeciesData>();
|
||||||
|
for (const s of allSpecies) map.set(s.speciesId, s);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProjectile(world: World, x: number, y: number, projData: Map<number, ProjectileData>): number {
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Position);
|
||||||
|
addComponent(world, eid, Projectile);
|
||||||
|
addComponent(world, eid, SpriteRef);
|
||||||
|
addComponent(world, eid, Velocity);
|
||||||
|
Position.x[eid] = x;
|
||||||
|
Position.y[eid] = y;
|
||||||
|
Projectile.lifetime[eid] = 2000;
|
||||||
|
SpriteRef.color[eid] = 0xffffff;
|
||||||
|
SpriteRef.radius[eid] = 5;
|
||||||
|
projData.set(eid, { itemId: 'Na' });
|
||||||
|
return eid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPlayer(world: World, x: number, y: number): number {
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Position);
|
||||||
|
addComponent(world, eid, PlayerTag);
|
||||||
|
addComponent(world, eid, Health);
|
||||||
|
Position.x[eid] = x;
|
||||||
|
Position.y[eid] = y;
|
||||||
|
Health.current[eid] = 100;
|
||||||
|
Health.max[eid] = 100;
|
||||||
|
return eid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Projectile-Creature Collision ───────────────────────────────
|
||||||
|
|
||||||
|
describe('Projectile-Creature Collision', () => {
|
||||||
|
let world: World;
|
||||||
|
const speciesLookup = buildSpeciesLookup();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = createWorld();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('projectile hits creature within range', () => {
|
||||||
|
const species = getSpecies('crystallid');
|
||||||
|
const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||||
|
const hpBefore = Health.current[creature];
|
||||||
|
|
||||||
|
const projData = new Map<number, ProjectileData>();
|
||||||
|
createProjectile(world, 105, 100, projData); // 5px away, within 20px hit radius
|
||||||
|
|
||||||
|
const hits = creatureProjectileSystem(world, projData, speciesLookup);
|
||||||
|
|
||||||
|
expect(hits).toHaveLength(1);
|
||||||
|
expect(hits[0].creatureEid).toBe(creature);
|
||||||
|
expect(hits[0].killed).toBe(false);
|
||||||
|
expect(Health.current[creature]).toBeLessThan(hpBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('projectile is removed after hitting creature', () => {
|
||||||
|
const species = getSpecies('crystallid');
|
||||||
|
createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||||
|
|
||||||
|
const projData = new Map<number, ProjectileData>();
|
||||||
|
const projEid = createProjectile(world, 105, 100, projData);
|
||||||
|
|
||||||
|
creatureProjectileSystem(world, projData, speciesLookup);
|
||||||
|
|
||||||
|
// Projectile should be removed
|
||||||
|
expect(projData.has(projEid)).toBe(false);
|
||||||
|
expect([...query(world, [Projectile])]).not.toContain(projEid);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('armor reduces damage (Crystallid has 0.3 armor)', () => {
|
||||||
|
const species = getSpecies('crystallid'); // armor = 0.3
|
||||||
|
const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||||
|
|
||||||
|
const projData = new Map<number, ProjectileData>();
|
||||||
|
createProjectile(world, 105, 100, projData);
|
||||||
|
|
||||||
|
const hits = creatureProjectileSystem(world, projData, speciesLookup);
|
||||||
|
|
||||||
|
// Base damage 15, armor 0.3 → 15 * 0.7 = 10.5 → rounded to 11
|
||||||
|
expect(hits[0].damage).toBe(11);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Reagent takes full damage (0 armor)', () => {
|
||||||
|
const species = getSpecies('reagent'); // armor = 0
|
||||||
|
const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||||
|
|
||||||
|
const projData = new Map<number, ProjectileData>();
|
||||||
|
createProjectile(world, 105, 100, projData);
|
||||||
|
|
||||||
|
const hits = creatureProjectileSystem(world, projData, speciesLookup);
|
||||||
|
|
||||||
|
expect(hits[0].damage).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('projectile misses creature outside range', () => {
|
||||||
|
const species = getSpecies('crystallid');
|
||||||
|
createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||||
|
|
||||||
|
const projData = new Map<number, ProjectileData>();
|
||||||
|
createProjectile(world, 200, 200, projData); // far away
|
||||||
|
|
||||||
|
const hits = creatureProjectileSystem(world, projData, speciesLookup);
|
||||||
|
|
||||||
|
expect(hits).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passive creature flees after being hit', () => {
|
||||||
|
const species = getSpecies('crystallid'); // aggressionRadius = 0 → passive
|
||||||
|
const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||||
|
|
||||||
|
const projData = new Map<number, ProjectileData>();
|
||||||
|
createProjectile(world, 105, 100, projData);
|
||||||
|
|
||||||
|
creatureProjectileSystem(world, projData, speciesLookup);
|
||||||
|
|
||||||
|
expect(AI.state[creature]).toBe(AIState.Flee);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aggressive creature attacks player after being hit', () => {
|
||||||
|
const species = getSpecies('acidophile'); // aggressionRadius = 100 → aggressive
|
||||||
|
const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||||
|
const player = createPlayer(world, 150, 100);
|
||||||
|
|
||||||
|
const projData = new Map<number, ProjectileData>();
|
||||||
|
createProjectile(world, 105, 100, projData);
|
||||||
|
|
||||||
|
creatureProjectileSystem(world, projData, speciesLookup);
|
||||||
|
|
||||||
|
expect(AI.state[creature]).toBe(AIState.Attack);
|
||||||
|
expect(AI.targetEid[creature]).toBe(player);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('killing a creature reports killed=true', () => {
|
||||||
|
const species = getSpecies('reagent'); // 60 hp
|
||||||
|
const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||||
|
Health.current[creature] = 10; // nearly dead
|
||||||
|
|
||||||
|
const projData = new Map<number, ProjectileData>();
|
||||||
|
createProjectile(world, 105, 100, projData);
|
||||||
|
|
||||||
|
const hits = creatureProjectileSystem(world, projData, speciesLookup);
|
||||||
|
|
||||||
|
expect(hits[0].killed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Creature Observation ────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Creature Observation', () => {
|
||||||
|
let world: World;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = createWorld();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects creature near player', () => {
|
||||||
|
const species = getSpecies('crystallid');
|
||||||
|
createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||||
|
createPlayer(world, 120, 100); // 20px away, within 60px observe range
|
||||||
|
|
||||||
|
const observations = getObservableCreatures(world);
|
||||||
|
|
||||||
|
expect(observations).toHaveLength(1);
|
||||||
|
expect(observations[0].speciesId).toBe(SpeciesId.Crystallid);
|
||||||
|
expect(observations[0].stage).toBe(LifeStage.Mature);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not detect distant creature', () => {
|
||||||
|
const species = getSpecies('crystallid');
|
||||||
|
createCreatureEntity(world, species, 500, 500, LifeStage.Mature);
|
||||||
|
createPlayer(world, 100, 100);
|
||||||
|
|
||||||
|
const observations = getObservableCreatures(world);
|
||||||
|
|
||||||
|
expect(observations).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns health and energy percentages', () => {
|
||||||
|
const species = getSpecies('crystallid');
|
||||||
|
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||||
|
Health.current[eid] = 60; // 50% of 120
|
||||||
|
Metabolism.energy[eid] = 50; // 50% of 100
|
||||||
|
createPlayer(world, 110, 100);
|
||||||
|
|
||||||
|
const observations = getObservableCreatures(world);
|
||||||
|
|
||||||
|
expect(observations[0].healthPercent).toBe(50);
|
||||||
|
expect(observations[0].energyPercent).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when no player', () => {
|
||||||
|
const species = getSpecies('crystallid');
|
||||||
|
createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||||
|
|
||||||
|
const observations = getObservableCreatures(world);
|
||||||
|
|
||||||
|
expect(observations).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Creature Attacks Player ─────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Creature Attacks Player', () => {
|
||||||
|
let world: World;
|
||||||
|
const speciesLookup = buildSpeciesLookup();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = createWorld();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attacking creature damages player in range', () => {
|
||||||
|
const species = getSpecies('reagent'); // attackRange = 18, damage = 20
|
||||||
|
const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||||
|
const player = createPlayer(world, 110, 100); // 10px away
|
||||||
|
|
||||||
|
AI.state[creature] = AIState.Attack;
|
||||||
|
AI.targetEid[creature] = player;
|
||||||
|
AI.attackCooldown[creature] = 0;
|
||||||
|
|
||||||
|
const damage = creatureAttackPlayerSystem(world, speciesLookup);
|
||||||
|
|
||||||
|
expect(damage).toBe(20);
|
||||||
|
expect(Health.current[player]).toBe(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no damage if creature is out of range', () => {
|
||||||
|
const species = getSpecies('reagent');
|
||||||
|
const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||||
|
const player = createPlayer(world, 200, 100); // 100px, beyond attackRange 18
|
||||||
|
|
||||||
|
AI.state[creature] = AIState.Attack;
|
||||||
|
AI.targetEid[creature] = player;
|
||||||
|
AI.attackCooldown[creature] = 0;
|
||||||
|
|
||||||
|
const damage = creatureAttackPlayerSystem(world, speciesLookup);
|
||||||
|
|
||||||
|
expect(damage).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attack cooldown prevents rapid attacks', () => {
|
||||||
|
const species = getSpecies('reagent');
|
||||||
|
const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||||
|
const player = createPlayer(world, 110, 100);
|
||||||
|
|
||||||
|
AI.state[creature] = AIState.Attack;
|
||||||
|
AI.targetEid[creature] = player;
|
||||||
|
AI.attackCooldown[creature] = 500; // on cooldown
|
||||||
|
|
||||||
|
const damage = creatureAttackPlayerSystem(world, speciesLookup);
|
||||||
|
|
||||||
|
expect(damage).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non-attacking creature does not damage player', () => {
|
||||||
|
const species = getSpecies('crystallid');
|
||||||
|
const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||||
|
const player = createPlayer(world, 110, 100);
|
||||||
|
|
||||||
|
AI.state[creature] = AIState.Wander; // not attacking
|
||||||
|
AI.attackCooldown[creature] = 0;
|
||||||
|
|
||||||
|
const damage = creatureAttackPlayerSystem(world, speciesLookup);
|
||||||
|
|
||||||
|
expect(damage).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
795
tests/creatures.test.ts
Normal file
795
tests/creatures.test.ts
Normal file
@@ -0,0 +1,795 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { createWorld, addEntity, addComponent, query } from 'bitecs';
|
||||||
|
import type { World } from '../src/ecs/world';
|
||||||
|
import {
|
||||||
|
Position,
|
||||||
|
Velocity,
|
||||||
|
Health,
|
||||||
|
SpriteRef,
|
||||||
|
Creature,
|
||||||
|
AI,
|
||||||
|
Metabolism,
|
||||||
|
LifeCycle,
|
||||||
|
Resource,
|
||||||
|
PlayerTag,
|
||||||
|
} from '../src/ecs/components';
|
||||||
|
import {
|
||||||
|
SpeciesId,
|
||||||
|
AIState,
|
||||||
|
LifeStage,
|
||||||
|
SpeciesRegistry,
|
||||||
|
} from '../src/creatures/types';
|
||||||
|
import type { SpeciesData, CreatureInfo } from '../src/creatures/types';
|
||||||
|
import { createCreatureEntity } from '../src/creatures/factory';
|
||||||
|
import { aiSystem } from '../src/creatures/ai';
|
||||||
|
import {
|
||||||
|
metabolismSystem,
|
||||||
|
resetMetabolismTracking,
|
||||||
|
} from '../src/creatures/metabolism';
|
||||||
|
import { lifeCycleSystem } from '../src/creatures/lifecycle';
|
||||||
|
import { countPopulations, reproduce } from '../src/creatures/population';
|
||||||
|
import speciesDataArray from '../src/data/creatures.json';
|
||||||
|
|
||||||
|
// ─── Test Helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const allSpecies = speciesDataArray as SpeciesData[];
|
||||||
|
|
||||||
|
function getSpecies(id: string): SpeciesData {
|
||||||
|
const s = allSpecies.find(s => s.id === id);
|
||||||
|
if (!s) throw new Error(`Species not found: ${id}`);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSpeciesLookup(): Map<number, SpeciesData> {
|
||||||
|
const map = new Map<number, SpeciesData>();
|
||||||
|
for (const s of allSpecies) {
|
||||||
|
map.set(s.speciesId, s);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createResourceEntity(world: World, x: number, y: number, qty: number): number {
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Position);
|
||||||
|
addComponent(world, eid, Resource);
|
||||||
|
Position.x[eid] = x;
|
||||||
|
Position.y[eid] = y;
|
||||||
|
Resource.quantity[eid] = qty;
|
||||||
|
Resource.interactRange[eid] = 40;
|
||||||
|
return eid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPlayerEntity(world: World, x: number, y: number): number {
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Position);
|
||||||
|
addComponent(world, eid, PlayerTag);
|
||||||
|
Position.x[eid] = x;
|
||||||
|
Position.y[eid] = y;
|
||||||
|
return eid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Species Data ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Species Data', () => {
|
||||||
|
it('loads 9 species from JSON (3 per biome)', () => {
|
||||||
|
expect(allSpecies).toHaveLength(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has Crystallid with correct properties', () => {
|
||||||
|
const c = getSpecies('crystallid');
|
||||||
|
expect(c.speciesId).toBe(SpeciesId.Crystallid);
|
||||||
|
expect(c.health).toBe(120);
|
||||||
|
expect(c.speed).toBe(30);
|
||||||
|
expect(c.armor).toBe(0.3);
|
||||||
|
expect(c.diet).toBe('mineral');
|
||||||
|
expect(c.excretionElement).toBe(14); // Si
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has Acidophile with correct properties', () => {
|
||||||
|
const a = getSpecies('acidophile');
|
||||||
|
expect(a.speciesId).toBe(SpeciesId.Acidophile);
|
||||||
|
expect(a.health).toBe(80);
|
||||||
|
expect(a.speed).toBe(50);
|
||||||
|
expect(a.diet).toBe('mineral');
|
||||||
|
expect(a.excretionElement).toBe(17); // Cl
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has Reagent as predator', () => {
|
||||||
|
const r = getSpecies('reagent');
|
||||||
|
expect(r.speciesId).toBe(SpeciesId.Reagent);
|
||||||
|
expect(r.speed).toBe(80);
|
||||||
|
expect(r.diet).toBe('creature');
|
||||||
|
expect(r.damage).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all species have valid life cycle durations', () => {
|
||||||
|
for (const species of allSpecies) {
|
||||||
|
expect(species.eggDuration).toBeGreaterThan(0);
|
||||||
|
expect(species.youthDuration).toBeGreaterThan(0);
|
||||||
|
expect(species.matureDuration).toBeGreaterThan(0);
|
||||||
|
expect(species.agingDuration).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all species have valid color hex strings', () => {
|
||||||
|
for (const species of allSpecies) {
|
||||||
|
expect(species.color).toMatch(/^#[0-9a-fA-F]{6}$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Species Registry ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Species Registry', () => {
|
||||||
|
let registry: SpeciesRegistry;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
registry = new SpeciesRegistry(allSpecies);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has correct count', () => {
|
||||||
|
expect(registry.count).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('looks up by string ID', () => {
|
||||||
|
const c = registry.get('crystallid');
|
||||||
|
expect(c?.name).toBe('Crystallid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('looks up by numeric ID', () => {
|
||||||
|
const a = registry.getByNumericId(SpeciesId.Acidophile);
|
||||||
|
expect(a?.id).toBe('acidophile');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for unknown ID', () => {
|
||||||
|
expect(registry.get('nonexistent')).toBeUndefined();
|
||||||
|
expect(registry.getByNumericId(99 as SpeciesId)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns all species', () => {
|
||||||
|
const all = registry.getAll();
|
||||||
|
expect(all).toHaveLength(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can look up new Phase 9 species', () => {
|
||||||
|
expect(registry.get('pendulum')?.biome).toBe('kinetic-mountains');
|
||||||
|
expect(registry.get('mechanoid')?.biome).toBe('kinetic-mountains');
|
||||||
|
expect(registry.get('resonator')?.biome).toBe('kinetic-mountains');
|
||||||
|
expect(registry.get('symbiote')?.biome).toBe('verdant-forests');
|
||||||
|
expect(registry.get('mimic')?.biome).toBe('verdant-forests');
|
||||||
|
expect(registry.get('spore-bearer')?.biome).toBe('verdant-forests');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each biome has exactly 3 species', () => {
|
||||||
|
const all = registry.getAll();
|
||||||
|
const byBiome = new Map<string, number>();
|
||||||
|
for (const s of all) {
|
||||||
|
byBiome.set(s.biome, (byBiome.get(s.biome) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
expect(byBiome.get('catalytic-wastes')).toBe(3);
|
||||||
|
expect(byBiome.get('kinetic-mountains')).toBe(3);
|
||||||
|
expect(byBiome.get('verdant-forests')).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Creature Factory ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Creature Factory', () => {
|
||||||
|
let world: World;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = createWorld();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates creature with all components', () => {
|
||||||
|
const species = getSpecies('crystallid');
|
||||||
|
const eid = createCreatureEntity(world, species, 100, 200);
|
||||||
|
|
||||||
|
expect(Position.x[eid]).toBe(100);
|
||||||
|
expect(Position.y[eid]).toBe(200);
|
||||||
|
expect(Creature.speciesId[eid]).toBe(SpeciesId.Crystallid);
|
||||||
|
expect(LifeCycle.stage[eid]).toBe(LifeStage.Egg);
|
||||||
|
expect(AI.state[eid]).toBe(AIState.Idle);
|
||||||
|
expect(Metabolism.energyMax[eid]).toBe(species.energyMax);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts as egg with reduced health', () => {
|
||||||
|
const species = getSpecies('crystallid');
|
||||||
|
const eid = createCreatureEntity(world, species, 0, 0, LifeStage.Egg);
|
||||||
|
|
||||||
|
expect(Health.current[eid]).toBe(Math.round(species.health * 0.3));
|
||||||
|
expect(LifeCycle.stage[eid]).toBe(LifeStage.Egg);
|
||||||
|
expect(SpriteRef.radius[eid]).toBe(species.radiusYouth);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates mature creature with full stats', () => {
|
||||||
|
const species = getSpecies('acidophile');
|
||||||
|
const eid = createCreatureEntity(world, species, 50, 50, LifeStage.Mature);
|
||||||
|
|
||||||
|
expect(Health.current[eid]).toBe(species.health);
|
||||||
|
expect(Health.max[eid]).toBe(species.health);
|
||||||
|
expect(LifeCycle.stage[eid]).toBe(LifeStage.Mature);
|
||||||
|
expect(SpriteRef.radius[eid]).toBe(species.radius);
|
||||||
|
expect(AI.state[eid]).toBe(AIState.Wander); // mature starts wandering
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets home position to spawn position', () => {
|
||||||
|
const species = getSpecies('reagent');
|
||||||
|
const eid = createCreatureEntity(world, species, 300, 400);
|
||||||
|
|
||||||
|
expect(AI.homeX[eid]).toBe(300);
|
||||||
|
expect(AI.homeY[eid]).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets metabolism from species data', () => {
|
||||||
|
const species = getSpecies('reagent');
|
||||||
|
const eid = createCreatureEntity(world, species, 0, 0, LifeStage.Mature);
|
||||||
|
|
||||||
|
expect(Metabolism.energyMax[eid]).toBe(species.energyMax);
|
||||||
|
expect(Metabolism.drainRate[eid]).toBe(species.energyDrainPerSecond);
|
||||||
|
expect(Metabolism.feedAmount[eid]).toBe(species.energyPerFeed);
|
||||||
|
expect(Metabolism.energy[eid]).toBe(species.energyMax * 0.7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queries creature entities correctly', () => {
|
||||||
|
const s1 = getSpecies('crystallid');
|
||||||
|
const s2 = getSpecies('reagent');
|
||||||
|
const e1 = createCreatureEntity(world, s1, 0, 0);
|
||||||
|
const e2 = createCreatureEntity(world, s2, 100, 100);
|
||||||
|
|
||||||
|
const creatures = [...query(world, [Creature])];
|
||||||
|
expect(creatures).toContain(e1);
|
||||||
|
expect(creatures).toContain(e2);
|
||||||
|
expect(creatures).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── AI System ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('AI System', () => {
|
||||||
|
let world: World;
|
||||||
|
const speciesLookup = buildSpeciesLookup();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = createWorld();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eggs do not move', () => {
|
||||||
|
const species = getSpecies('crystallid');
|
||||||
|
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Egg);
|
||||||
|
|
||||||
|
aiSystem(world, 16, speciesLookup, 1);
|
||||||
|
|
||||||
|
expect(Velocity.vx[eid]).toBe(0);
|
||||||
|
expect(Velocity.vy[eid]).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('idle creatures have zero velocity', () => {
|
||||||
|
const species = getSpecies('crystallid');
|
||||||
|
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||||
|
AI.state[eid] = AIState.Idle;
|
||||||
|
AI.stateTimer[eid] = 5000;
|
||||||
|
|
||||||
|
aiSystem(world, 16, speciesLookup, 1);
|
||||||
|
|
||||||
|
expect(Velocity.vx[eid]).toBe(0);
|
||||||
|
expect(Velocity.vy[eid]).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wandering creatures have non-zero velocity', () => {
|
||||||
|
const species = getSpecies('crystallid');
|
||||||
|
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||||
|
AI.state[eid] = AIState.Wander;
|
||||||
|
AI.stateTimer[eid] = 5000;
|
||||||
|
|
||||||
|
aiSystem(world, 16, speciesLookup, 1);
|
||||||
|
|
||||||
|
const speed = Math.sqrt(Velocity.vx[eid] ** 2 + Velocity.vy[eid] ** 2);
|
||||||
|
expect(speed).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creature flees from nearby player', () => {
|
||||||
|
const species = getSpecies('crystallid'); // fleeRadius = 80
|
||||||
|
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||||
|
AI.state[eid] = AIState.Wander;
|
||||||
|
AI.stateTimer[eid] = 5000;
|
||||||
|
|
||||||
|
// Place player within flee radius
|
||||||
|
createPlayerEntity(world, 130, 100); // 30px away, within 80
|
||||||
|
|
||||||
|
aiSystem(world, 16, speciesLookup, 1);
|
||||||
|
|
||||||
|
expect(AI.state[eid]).toBe(AIState.Flee);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Reagents do not flee from player', () => {
|
||||||
|
const species = getSpecies('reagent');
|
||||||
|
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||||
|
AI.state[eid] = AIState.Wander;
|
||||||
|
AI.stateTimer[eid] = 5000;
|
||||||
|
|
||||||
|
createPlayerEntity(world, 110, 100); // very close
|
||||||
|
|
||||||
|
aiSystem(world, 16, speciesLookup, 1);
|
||||||
|
|
||||||
|
expect(AI.state[eid]).not.toBe(AIState.Flee);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hungry creature enters Feed state', () => {
|
||||||
|
const species = getSpecies('crystallid');
|
||||||
|
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||||
|
AI.state[eid] = AIState.Wander;
|
||||||
|
AI.stateTimer[eid] = 5000;
|
||||||
|
|
||||||
|
// Set energy below hunger threshold
|
||||||
|
Metabolism.energy[eid] = species.energyMax * 0.2; // well below 0.4 threshold
|
||||||
|
|
||||||
|
aiSystem(world, 16, speciesLookup, 1);
|
||||||
|
|
||||||
|
expect(AI.state[eid]).toBe(AIState.Feed);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Reagent attacks nearby non-Reagent creature when hungry', () => {
|
||||||
|
const reagent = getSpecies('reagent');
|
||||||
|
const crystallid = getSpecies('crystallid');
|
||||||
|
|
||||||
|
const predator = createCreatureEntity(world, reagent, 100, 100, LifeStage.Mature);
|
||||||
|
const prey = createCreatureEntity(world, crystallid, 120, 100, LifeStage.Mature); // 20px away, within aggressionRadius 150
|
||||||
|
|
||||||
|
// Make predator hungry
|
||||||
|
Metabolism.energy[predator] = reagent.energyMax * 0.3;
|
||||||
|
AI.state[predator] = AIState.Wander;
|
||||||
|
AI.stateTimer[predator] = 5000;
|
||||||
|
|
||||||
|
aiSystem(world, 16, speciesLookup, 1);
|
||||||
|
|
||||||
|
expect(AI.state[predator]).toBe(AIState.Attack);
|
||||||
|
expect(AI.targetEid[predator]).toBe(prey);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attacking creature deals damage when in range', () => {
|
||||||
|
const reagent = getSpecies('reagent');
|
||||||
|
const crystallid = getSpecies('crystallid');
|
||||||
|
|
||||||
|
const predator = createCreatureEntity(world, reagent, 100, 100, LifeStage.Mature);
|
||||||
|
const prey = createCreatureEntity(world, crystallid, 110, 100, LifeStage.Mature); // 10px, within attackRange 18
|
||||||
|
|
||||||
|
const preyHpBefore = Health.current[prey];
|
||||||
|
|
||||||
|
AI.state[predator] = AIState.Attack;
|
||||||
|
AI.targetEid[predator] = prey;
|
||||||
|
AI.stateTimer[predator] = 5000;
|
||||||
|
AI.attackCooldown[predator] = 0;
|
||||||
|
Metabolism.energy[predator] = reagent.energyMax * 0.3;
|
||||||
|
|
||||||
|
aiSystem(world, 16, speciesLookup, 1);
|
||||||
|
|
||||||
|
expect(Health.current[prey]).toBeLessThan(preyHpBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('state transitions on timer expiry (idle → wander)', () => {
|
||||||
|
const species = getSpecies('crystallid');
|
||||||
|
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||||
|
AI.state[eid] = AIState.Idle;
|
||||||
|
AI.stateTimer[eid] = 10; // almost expired
|
||||||
|
|
||||||
|
aiSystem(world, 20, speciesLookup, 1); // 20ms > 10ms timer
|
||||||
|
|
||||||
|
expect(AI.state[eid]).toBe(AIState.Wander);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wander → idle on timer expiry', () => {
|
||||||
|
const species = getSpecies('crystallid');
|
||||||
|
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||||
|
AI.state[eid] = AIState.Wander;
|
||||||
|
AI.stateTimer[eid] = 10;
|
||||||
|
|
||||||
|
aiSystem(world, 20, speciesLookup, 1);
|
||||||
|
|
||||||
|
expect(AI.state[eid]).toBe(AIState.Idle);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creature returns to home when too far', () => {
|
||||||
|
const species = getSpecies('crystallid'); // wanderRadius = 200
|
||||||
|
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||||
|
AI.homeX[eid] = 100;
|
||||||
|
AI.homeY[eid] = 100;
|
||||||
|
|
||||||
|
// Move creature far from home
|
||||||
|
Position.x[eid] = 500; // 400px away, beyond wanderRadius 200
|
||||||
|
Position.y[eid] = 100;
|
||||||
|
|
||||||
|
AI.state[eid] = AIState.Wander;
|
||||||
|
AI.stateTimer[eid] = 5000;
|
||||||
|
|
||||||
|
aiSystem(world, 16, speciesLookup, 1);
|
||||||
|
|
||||||
|
// Should be moving toward home (negative vx toward 100)
|
||||||
|
expect(Velocity.vx[eid]).toBeLessThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Metabolism System ───────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Metabolism System', () => {
|
||||||
|
let world: World;
|
||||||
|
const emptyResourceData = new Map<number, { itemId: string; tileX: number; tileY: number }>();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = createWorld();
|
||||||
|
resetMetabolismTracking();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drains energy over time', () => {
|
||||||
|
const species = getSpecies('crystallid');
|
||||||
|
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||||
|
const energyBefore = Metabolism.energy[eid];
|
||||||
|
|
||||||
|
metabolismSystem(world, 1000, emptyResourceData, 1000); // 1 second
|
||||||
|
|
||||||
|
expect(Metabolism.energy[eid]).toBeLessThan(energyBefore);
|
||||||
|
expect(Metabolism.energy[eid]).toBeCloseTo(
|
||||||
|
energyBefore - species.energyDrainPerSecond,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eggs do not metabolize', () => {
|
||||||
|
const species = getSpecies('crystallid');
|
||||||
|
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Egg);
|
||||||
|
const energyBefore = Metabolism.energy[eid];
|
||||||
|
|
||||||
|
metabolismSystem(world, 1000, emptyResourceData, 1000);
|
||||||
|
|
||||||
|
expect(Metabolism.energy[eid]).toBe(energyBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starvation deals damage when energy = 0', () => {
|
||||||
|
const species = getSpecies('crystallid');
|
||||||
|
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||||
|
Metabolism.energy[eid] = 0;
|
||||||
|
const hpBefore = Health.current[eid];
|
||||||
|
|
||||||
|
metabolismSystem(world, 1000, emptyResourceData, 1000);
|
||||||
|
|
||||||
|
expect(Health.current[eid]).toBeLessThan(hpBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creature feeds from nearby resource', () => {
|
||||||
|
const species = getSpecies('crystallid');
|
||||||
|
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||||
|
AI.state[eid] = AIState.Feed;
|
||||||
|
Metabolism.energy[eid] = 20; // low energy
|
||||||
|
|
||||||
|
// Place resource next to creature
|
||||||
|
const resEid = createResourceEntity(world, 110, 100, 5);
|
||||||
|
|
||||||
|
metabolismSystem(world, 16, emptyResourceData, 5000);
|
||||||
|
|
||||||
|
// Should have gained energy
|
||||||
|
expect(Metabolism.energy[eid]).toBeGreaterThan(20);
|
||||||
|
// Resource should be depleted by 1
|
||||||
|
expect(Resource.quantity[resEid]).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns excretion events after feeding', () => {
|
||||||
|
const species = getSpecies('crystallid');
|
||||||
|
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||||
|
AI.state[eid] = AIState.Feed;
|
||||||
|
Metabolism.energy[eid] = 20;
|
||||||
|
|
||||||
|
createResourceEntity(world, 110, 100, 5);
|
||||||
|
|
||||||
|
const excretions = metabolismSystem(world, 16, emptyResourceData, 5000);
|
||||||
|
|
||||||
|
expect(excretions.length).toBeGreaterThan(0);
|
||||||
|
expect(excretions[0][0]).toBe(eid); // creature eid
|
||||||
|
expect(excretions[0][1]).toBe(SpeciesId.Crystallid);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('energy does not exceed max', () => {
|
||||||
|
const species = getSpecies('crystallid');
|
||||||
|
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||||
|
AI.state[eid] = AIState.Feed;
|
||||||
|
Metabolism.energy[eid] = species.energyMax - 1; // almost full
|
||||||
|
|
||||||
|
createResourceEntity(world, 110, 100, 5);
|
||||||
|
|
||||||
|
metabolismSystem(world, 16, emptyResourceData, 5000);
|
||||||
|
|
||||||
|
expect(Metabolism.energy[eid]).toBe(species.energyMax);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('youth drains energy slower (0.7x)', () => {
|
||||||
|
const species = getSpecies('crystallid');
|
||||||
|
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Youth);
|
||||||
|
const energyBefore = Metabolism.energy[eid];
|
||||||
|
|
||||||
|
metabolismSystem(world, 1000, emptyResourceData, 1000);
|
||||||
|
|
||||||
|
const drained = energyBefore - Metabolism.energy[eid];
|
||||||
|
expect(drained).toBeCloseTo(species.energyDrainPerSecond * 0.7, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aging drains energy faster (1.3x)', () => {
|
||||||
|
const species = getSpecies('crystallid');
|
||||||
|
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Aging);
|
||||||
|
const energyBefore = Metabolism.energy[eid];
|
||||||
|
|
||||||
|
metabolismSystem(world, 1000, emptyResourceData, 1000);
|
||||||
|
|
||||||
|
const drained = energyBefore - Metabolism.energy[eid];
|
||||||
|
expect(drained).toBeCloseTo(species.energyDrainPerSecond * 1.3, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Life Cycle System ───────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Life Cycle System', () => {
|
||||||
|
let world: World;
|
||||||
|
const speciesLookup = buildSpeciesLookup();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = createWorld();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('advances age over time', () => {
|
||||||
|
const species = getSpecies('crystallid');
|
||||||
|
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||||
|
|
||||||
|
lifeCycleSystem(world, 1000, speciesLookup);
|
||||||
|
|
||||||
|
expect(LifeCycle.age[eid]).toBe(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('egg hatches to youth when timer expires', () => {
|
||||||
|
const species = getSpecies('crystallid');
|
||||||
|
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Egg);
|
||||||
|
|
||||||
|
// Fast-forward past egg duration
|
||||||
|
const events = lifeCycleSystem(world, species.eggDuration + 100, speciesLookup);
|
||||||
|
|
||||||
|
expect(LifeCycle.stage[eid]).toBe(LifeStage.Youth);
|
||||||
|
expect(events.some(e => e.type === 'stage_advance' && e.newStage === LifeStage.Youth)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('youth grows to mature', () => {
|
||||||
|
const species = getSpecies('crystallid');
|
||||||
|
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Youth);
|
||||||
|
|
||||||
|
const events = lifeCycleSystem(world, species.youthDuration + 100, speciesLookup);
|
||||||
|
|
||||||
|
expect(LifeCycle.stage[eid]).toBe(LifeStage.Mature);
|
||||||
|
expect(Health.current[eid]).toBe(species.health); // full health
|
||||||
|
expect(SpriteRef.radius[eid]).toBe(species.radius); // full size
|
||||||
|
expect(events.some(e => e.type === 'stage_advance' && e.newStage === LifeStage.Mature)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mature ages to aging', () => {
|
||||||
|
const species = getSpecies('crystallid');
|
||||||
|
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||||
|
|
||||||
|
const events = lifeCycleSystem(world, species.matureDuration + 100, speciesLookup);
|
||||||
|
|
||||||
|
expect(LifeCycle.stage[eid]).toBe(LifeStage.Aging);
|
||||||
|
expect(Health.max[eid]).toBe(Math.round(species.health * 0.7)); // reduced
|
||||||
|
expect(events.some(e => e.type === 'stage_advance' && e.newStage === LifeStage.Aging)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aging leads to natural death', () => {
|
||||||
|
const species = getSpecies('crystallid');
|
||||||
|
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Aging);
|
||||||
|
|
||||||
|
const events = lifeCycleSystem(world, species.agingDuration + 100, speciesLookup);
|
||||||
|
|
||||||
|
expect(Health.current[eid]).toBe(0);
|
||||||
|
expect(events.some(e => e.type === 'natural_death')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports ready_to_reproduce for mature creatures with high energy', () => {
|
||||||
|
const species = getSpecies('crystallid');
|
||||||
|
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||||
|
Metabolism.energy[eid] = species.energyMax * 0.9; // above 0.8 threshold
|
||||||
|
|
||||||
|
// Don't let timer expire
|
||||||
|
LifeCycle.stageTimer[eid] = 99999;
|
||||||
|
|
||||||
|
const events = lifeCycleSystem(world, 100, speciesLookup);
|
||||||
|
|
||||||
|
expect(events.some(e => e.type === 'ready_to_reproduce' && e.eid === eid)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not report reproduction for youth', () => {
|
||||||
|
const species = getSpecies('crystallid');
|
||||||
|
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Youth);
|
||||||
|
Metabolism.energy[eid] = species.energyMax; // full energy but youth
|
||||||
|
|
||||||
|
LifeCycle.stageTimer[eid] = 99999;
|
||||||
|
|
||||||
|
const events = lifeCycleSystem(world, 100, speciesLookup);
|
||||||
|
|
||||||
|
expect(events.some(e => e.type === 'ready_to_reproduce' && e.eid === eid)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('full lifecycle: egg → youth → mature → aging → death', () => {
|
||||||
|
const species = getSpecies('acidophile');
|
||||||
|
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Egg);
|
||||||
|
|
||||||
|
// Egg → Youth
|
||||||
|
lifeCycleSystem(world, species.eggDuration + 1, speciesLookup);
|
||||||
|
expect(LifeCycle.stage[eid]).toBe(LifeStage.Youth);
|
||||||
|
|
||||||
|
// Youth → Mature
|
||||||
|
lifeCycleSystem(world, species.youthDuration + 1, speciesLookup);
|
||||||
|
expect(LifeCycle.stage[eid]).toBe(LifeStage.Mature);
|
||||||
|
|
||||||
|
// Mature → Aging
|
||||||
|
lifeCycleSystem(world, species.matureDuration + 1, speciesLookup);
|
||||||
|
expect(LifeCycle.stage[eid]).toBe(LifeStage.Aging);
|
||||||
|
|
||||||
|
// Aging → Death
|
||||||
|
const events = lifeCycleSystem(world, species.agingDuration + 1, speciesLookup);
|
||||||
|
expect(Health.current[eid]).toBe(0);
|
||||||
|
expect(events.some(e => e.type === 'natural_death')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Population Dynamics ─────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Population Dynamics', () => {
|
||||||
|
let world: World;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = createWorld();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts populations correctly', () => {
|
||||||
|
const s1 = getSpecies('crystallid');
|
||||||
|
const s2 = getSpecies('reagent');
|
||||||
|
|
||||||
|
createCreatureEntity(world, s1, 0, 0, LifeStage.Mature);
|
||||||
|
createCreatureEntity(world, s1, 100, 0, LifeStage.Mature);
|
||||||
|
createCreatureEntity(world, s2, 200, 0, LifeStage.Mature);
|
||||||
|
|
||||||
|
const counts = countPopulations(world);
|
||||||
|
|
||||||
|
expect(counts.get(SpeciesId.Crystallid)).toBe(2);
|
||||||
|
expect(counts.get(SpeciesId.Reagent)).toBe(1);
|
||||||
|
expect(counts.get(SpeciesId.Acidophile)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reproduce creates offspring near parent', () => {
|
||||||
|
const species = getSpecies('crystallid'); // offspringCount = 2
|
||||||
|
const parent = createCreatureEntity(world, species, 200, 200, LifeStage.Mature);
|
||||||
|
|
||||||
|
const creatureData = new Map<number, CreatureInfo>();
|
||||||
|
const newEids = reproduce(world, parent, species, 1, creatureData);
|
||||||
|
|
||||||
|
expect(newEids).toHaveLength(2);
|
||||||
|
|
||||||
|
for (const eid of newEids) {
|
||||||
|
// Offspring should be eggs near parent
|
||||||
|
expect(LifeCycle.stage[eid]).toBe(LifeStage.Egg);
|
||||||
|
const dx = Math.abs(Position.x[eid] - 200);
|
||||||
|
const dy = Math.abs(Position.y[eid] - 200);
|
||||||
|
expect(dx).toBeLessThanOrEqual(25);
|
||||||
|
expect(dy).toBeLessThanOrEqual(25);
|
||||||
|
|
||||||
|
// Should be tracked in creature data
|
||||||
|
expect(creatureData.has(eid)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reproduce respects population cap', () => {
|
||||||
|
const species = getSpecies('reagent'); // maxPopulation = 8, offspringCount = 1
|
||||||
|
const parent = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||||
|
|
||||||
|
const creatureData = new Map<number, CreatureInfo>();
|
||||||
|
|
||||||
|
// At max population
|
||||||
|
const newEids = reproduce(world, parent, species, 8, creatureData);
|
||||||
|
expect(newEids).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reproduce limits offspring to not exceed cap', () => {
|
||||||
|
const species = getSpecies('crystallid'); // maxPopulation = 12, offspringCount = 2
|
||||||
|
const parent = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||||
|
|
||||||
|
const creatureData = new Map<number, CreatureInfo>();
|
||||||
|
|
||||||
|
// Population at 11, only room for 1 offspring
|
||||||
|
const newEids = reproduce(world, parent, species, 11, creatureData);
|
||||||
|
expect(newEids).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Integration ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Creature Integration', () => {
|
||||||
|
let world: World;
|
||||||
|
const speciesLookup = buildSpeciesLookup();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = createWorld();
|
||||||
|
resetMetabolismTracking();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creature lifecycle: hatch → feed → reproduce → age → die', () => {
|
||||||
|
const species = getSpecies('acidophile');
|
||||||
|
const eid = createCreatureEntity(world, species, 200, 200, LifeStage.Egg);
|
||||||
|
|
||||||
|
// 1. Hatch
|
||||||
|
lifeCycleSystem(world, species.eggDuration + 1, speciesLookup);
|
||||||
|
expect(LifeCycle.stage[eid]).toBe(LifeStage.Youth);
|
||||||
|
|
||||||
|
// 2. Grow to mature
|
||||||
|
lifeCycleSystem(world, species.youthDuration + 1, speciesLookup);
|
||||||
|
expect(LifeCycle.stage[eid]).toBe(LifeStage.Mature);
|
||||||
|
|
||||||
|
// 3. AI works — creature wanders
|
||||||
|
aiSystem(world, 16, speciesLookup, 1);
|
||||||
|
const speed = Math.sqrt(Velocity.vx[eid] ** 2 + Velocity.vy[eid] ** 2);
|
||||||
|
expect(speed).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// 4. Metabolism drains energy
|
||||||
|
const energyBefore = Metabolism.energy[eid];
|
||||||
|
metabolismSystem(world, 1000, new Map(), 10000);
|
||||||
|
expect(Metabolism.energy[eid]).toBeLessThan(energyBefore);
|
||||||
|
|
||||||
|
// 5. Age to aging
|
||||||
|
lifeCycleSystem(world, species.matureDuration + 1, speciesLookup);
|
||||||
|
expect(LifeCycle.stage[eid]).toBe(LifeStage.Aging);
|
||||||
|
|
||||||
|
// 6. Natural death
|
||||||
|
const events = lifeCycleSystem(world, species.agingDuration + 1, speciesLookup);
|
||||||
|
expect(events.some(e => e.type === 'natural_death')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('predator-prey interaction: reagent kills crystallid', () => {
|
||||||
|
const reagent = getSpecies('reagent');
|
||||||
|
const crystallid = getSpecies('crystallid');
|
||||||
|
|
||||||
|
const pred = createCreatureEntity(world, reagent, 100, 100, LifeStage.Mature);
|
||||||
|
const prey = createCreatureEntity(world, crystallid, 110, 100, LifeStage.Mature);
|
||||||
|
|
||||||
|
// Set predator to attack
|
||||||
|
AI.state[pred] = AIState.Attack;
|
||||||
|
AI.targetEid[pred] = prey;
|
||||||
|
AI.stateTimer[pred] = 10000;
|
||||||
|
AI.attackCooldown[pred] = 0;
|
||||||
|
Metabolism.energy[pred] = reagent.energyMax * 0.3;
|
||||||
|
|
||||||
|
const initialHp = Health.current[prey];
|
||||||
|
|
||||||
|
// Simulate several attack cycles
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
aiSystem(world, 1000, speciesLookup, i);
|
||||||
|
AI.attackCooldown[pred] = 0; // reset cooldown for test
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(Health.current[prey]).toBeLessThanOrEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creature ecosystem with multiple species runs without errors', () => {
|
||||||
|
const species = allSpecies;
|
||||||
|
|
||||||
|
// Spawn several of each
|
||||||
|
for (const s of species) {
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
createCreatureEntity(world, s, 100 + i * 50, 100 + s.speciesId * 100, LifeStage.Mature);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run 100 ticks
|
||||||
|
for (let tick = 0; tick < 100; tick++) {
|
||||||
|
aiSystem(world, 16, speciesLookup, tick);
|
||||||
|
metabolismSystem(world, 16, new Map(), tick * 16);
|
||||||
|
lifeCycleSystem(world, 16, speciesLookup);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should still have creatures alive
|
||||||
|
const counts = countPopulations(world);
|
||||||
|
const total = [...counts.values()].reduce((a, b) => a + b, 0);
|
||||||
|
expect(total).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
280
tests/ecosystem.test.ts
Normal file
280
tests/ecosystem.test.ts
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
/**
|
||||||
|
* Ecosystem Test — verify populations fluctuate without dying out
|
||||||
|
*
|
||||||
|
* Runs a headless simulation with all creature systems active,
|
||||||
|
* no player present. Verifies:
|
||||||
|
* - Populations oscillate (Lotka-Volterra dynamics)
|
||||||
|
* - No species goes fully extinct within the test window
|
||||||
|
* - Reproduction keeps populations alive
|
||||||
|
* - Predation reduces prey population
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { createWorld, addEntity, addComponent, query } from 'bitecs';
|
||||||
|
import type { World } from '../src/ecs/world';
|
||||||
|
import { Position, Resource, Creature, Health, Metabolism, LifeCycle, AI } from '../src/ecs/components';
|
||||||
|
import { SpeciesId, LifeStage, AIState } from '../src/creatures/types';
|
||||||
|
import type { SpeciesData, CreatureInfo } from '../src/creatures/types';
|
||||||
|
import { createCreatureEntity } from '../src/creatures/factory';
|
||||||
|
import { aiSystem } from '../src/creatures/ai';
|
||||||
|
import { metabolismSystem, resetMetabolismTracking } from '../src/creatures/metabolism';
|
||||||
|
import { lifeCycleSystem } from '../src/creatures/lifecycle';
|
||||||
|
import { countPopulations, reproduce } from '../src/creatures/population';
|
||||||
|
import { movementSystem } from '../src/ecs/systems/movement';
|
||||||
|
import { healthSystem } from '../src/ecs/systems/health';
|
||||||
|
import { removeGameEntity } from '../src/ecs/factory';
|
||||||
|
import speciesDataArray from '../src/data/creatures.json';
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const allSpecies = speciesDataArray as SpeciesData[];
|
||||||
|
|
||||||
|
function getSpecies(id: string): SpeciesData {
|
||||||
|
const s = allSpecies.find(s => s.id === id);
|
||||||
|
if (!s) throw new Error(`Species not found: ${id}`);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSpeciesLookup(): Map<number, SpeciesData> {
|
||||||
|
const map = new Map<number, SpeciesData>();
|
||||||
|
for (const s of allSpecies) map.set(s.speciesId, s);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createResourceField(world: World, count: number): void {
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Position);
|
||||||
|
addComponent(world, eid, Resource);
|
||||||
|
Position.x[eid] = (i % 10) * 50 + 25;
|
||||||
|
Position.y[eid] = Math.floor(i / 10) * 50 + 25;
|
||||||
|
Resource.quantity[eid] = 999; // infinite for ecosystem test
|
||||||
|
Resource.interactRange[eid] = 40;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run one simulation tick.
|
||||||
|
* Mimics what GameScene.update() does, minus Phaser-specific parts.
|
||||||
|
*/
|
||||||
|
function simulateTick(
|
||||||
|
world: World,
|
||||||
|
deltaMs: number,
|
||||||
|
speciesLookup: Map<number, SpeciesData>,
|
||||||
|
tick: number,
|
||||||
|
creatureData: Map<number, CreatureInfo>,
|
||||||
|
): void {
|
||||||
|
const elapsed = tick * deltaMs;
|
||||||
|
|
||||||
|
// AI
|
||||||
|
aiSystem(world, deltaMs, speciesLookup, tick);
|
||||||
|
|
||||||
|
// Movement
|
||||||
|
movementSystem(world, deltaMs);
|
||||||
|
|
||||||
|
// Metabolism
|
||||||
|
metabolismSystem(world, deltaMs, new Map(), elapsed);
|
||||||
|
|
||||||
|
// Life cycle
|
||||||
|
const lcEvents = lifeCycleSystem(world, deltaMs, speciesLookup);
|
||||||
|
|
||||||
|
// Reproduction
|
||||||
|
const populations = countPopulations(world);
|
||||||
|
for (const event of lcEvents) {
|
||||||
|
if (event.type === 'ready_to_reproduce') {
|
||||||
|
const species = speciesLookup.get(event.speciesId);
|
||||||
|
if (species) {
|
||||||
|
const currentPop = populations.get(event.speciesId) ?? 0;
|
||||||
|
reproduce(world, event.eid, species, currentPop, creatureData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health / death
|
||||||
|
const dead = healthSystem(world);
|
||||||
|
for (const eid of dead) {
|
||||||
|
creatureData.delete(eid);
|
||||||
|
removeGameEntity(world, eid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Ecosystem Tests ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Ecosystem Simulation', () => {
|
||||||
|
let world: World;
|
||||||
|
const speciesLookup = buildSpeciesLookup();
|
||||||
|
const DELTA = 100; // 100ms per tick (10 fps for speed)
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = createWorld();
|
||||||
|
resetMetabolismTracking();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('single species population stabilizes with food', () => {
|
||||||
|
const species = getSpecies('crystallid');
|
||||||
|
const creatureData = new Map<number, CreatureInfo>();
|
||||||
|
|
||||||
|
// Spawn 5 mature Crystallids
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const eid = createCreatureEntity(
|
||||||
|
world, species, 50 + i * 40, 50, LifeStage.Mature,
|
||||||
|
);
|
||||||
|
// Give them plenty of energy so they can reproduce
|
||||||
|
Metabolism.energy[eid] = species.energyMax;
|
||||||
|
creatureData.set(eid, { speciesId: species.speciesId, speciesDataId: species.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abundant food
|
||||||
|
createResourceField(world, 30);
|
||||||
|
|
||||||
|
// Simulate 200 ticks (20 seconds game time)
|
||||||
|
const populationHistory: number[] = [];
|
||||||
|
for (let t = 0; t < 200; t++) {
|
||||||
|
simulateTick(world, DELTA, speciesLookup, t, creatureData);
|
||||||
|
if (t % 10 === 0) {
|
||||||
|
const counts = countPopulations(world);
|
||||||
|
populationHistory.push(counts.get(SpeciesId.Crystallid) ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Population should not die out
|
||||||
|
const finalCount = countPopulations(world).get(SpeciesId.Crystallid) ?? 0;
|
||||||
|
expect(finalCount).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Population should have some variation (not flat)
|
||||||
|
const min = Math.min(...populationHistory);
|
||||||
|
const max = Math.max(...populationHistory);
|
||||||
|
// At minimum, we should have had some births (max > initial 5)
|
||||||
|
expect(max).toBeGreaterThanOrEqual(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('predator-prey dynamics: reagents reduce prey population', () => {
|
||||||
|
const crystallid = getSpecies('crystallid');
|
||||||
|
const reagent = getSpecies('reagent');
|
||||||
|
const creatureData = new Map<number, CreatureInfo>();
|
||||||
|
|
||||||
|
// Spawn prey
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const eid = createCreatureEntity(
|
||||||
|
world, crystallid, 100 + i * 30, 100, LifeStage.Mature,
|
||||||
|
);
|
||||||
|
Metabolism.energy[eid] = crystallid.energyMax;
|
||||||
|
creatureData.set(eid, { speciesId: crystallid.speciesId, speciesDataId: crystallid.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn predators
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const eid = createCreatureEntity(
|
||||||
|
world, reagent, 100 + i * 30, 130, LifeStage.Mature,
|
||||||
|
);
|
||||||
|
Metabolism.energy[eid] = reagent.energyMax * 0.3; // hungry!
|
||||||
|
creatureData.set(eid, { speciesId: reagent.speciesId, speciesDataId: reagent.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
createResourceField(world, 20);
|
||||||
|
|
||||||
|
// Record initial prey count
|
||||||
|
const initialPrey = countPopulations(world).get(SpeciesId.Crystallid) ?? 0;
|
||||||
|
expect(initialPrey).toBe(6);
|
||||||
|
|
||||||
|
// Simulate for a while — predators should hunt
|
||||||
|
for (let t = 0; t < 300; t++) {
|
||||||
|
simulateTick(world, DELTA, speciesLookup, t, creatureData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some prey should have been killed by predators
|
||||||
|
const finalPrey = countPopulations(world).get(SpeciesId.Crystallid) ?? 0;
|
||||||
|
// Either prey died from predation/aging, or reproduced
|
||||||
|
// The key check: something happened (population changed)
|
||||||
|
const totalCreatures = [...countPopulations(world).values()].reduce((a, b) => a + b, 0);
|
||||||
|
expect(totalCreatures).toBeGreaterThan(0); // ecosystem still alive
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starvation without food leads to population decline', () => {
|
||||||
|
const species = getSpecies('acidophile');
|
||||||
|
const creatureData = new Map<number, CreatureInfo>();
|
||||||
|
|
||||||
|
// Spawn creatures with low energy and NO food sources
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const eid = createCreatureEntity(
|
||||||
|
world, species, 50 + i * 40, 50, LifeStage.Mature,
|
||||||
|
);
|
||||||
|
Metabolism.energy[eid] = 10; // very low energy, no food available
|
||||||
|
creatureData.set(eid, { speciesId: species.speciesId, speciesDataId: species.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// NO food! Simulate starvation (need ~25s for full drain + death at 100ms/tick)
|
||||||
|
for (let t = 0; t < 300; t++) {
|
||||||
|
simulateTick(world, DELTA, speciesLookup, t, creatureData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Population should decline due to starvation
|
||||||
|
const finalCount = countPopulations(world).get(SpeciesId.Acidophile) ?? 0;
|
||||||
|
expect(finalCount).toBeLessThan(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mixed ecosystem runs without errors for 500 ticks', () => {
|
||||||
|
const creatureData = new Map<number, CreatureInfo>();
|
||||||
|
|
||||||
|
// Spawn a balanced mix
|
||||||
|
for (const species of allSpecies) {
|
||||||
|
const count = species.id === 'reagent' ? 2 : 4;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const eid = createCreatureEntity(
|
||||||
|
world, species,
|
||||||
|
50 + i * 60 + species.speciesId * 200,
|
||||||
|
50 + species.speciesId * 100,
|
||||||
|
LifeStage.Mature,
|
||||||
|
);
|
||||||
|
Metabolism.energy[eid] = species.energyMax * 0.8;
|
||||||
|
creatureData.set(eid, { speciesId: species.speciesId, speciesDataId: species.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createResourceField(world, 40);
|
||||||
|
|
||||||
|
// Run 500 ticks — should not throw
|
||||||
|
for (let t = 0; t < 500; t++) {
|
||||||
|
simulateTick(world, DELTA, speciesLookup, t, creatureData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// At least some creatures should survive
|
||||||
|
const total = [...countPopulations(world).values()].reduce((a, b) => a + b, 0);
|
||||||
|
expect(total).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lifecycle drives population renewal', () => {
|
||||||
|
const species = getSpecies('acidophile');
|
||||||
|
const creatureData = new Map<number, CreatureInfo>();
|
||||||
|
|
||||||
|
// Start with eggs — they should hatch, grow, reproduce
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const eid = createCreatureEntity(
|
||||||
|
world, species, 50 + i * 30, 50, LifeStage.Egg,
|
||||||
|
);
|
||||||
|
creatureData.set(eid, { speciesId: species.speciesId, speciesDataId: species.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
createResourceField(world, 20);
|
||||||
|
|
||||||
|
// Run long enough for eggs to hatch and creatures to mature
|
||||||
|
// egg(6s) + youth(12s) = 18s → need 180 ticks at 100ms/tick
|
||||||
|
for (let t = 0; t < 250; t++) {
|
||||||
|
simulateTick(world, DELTA, speciesLookup, t, creatureData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have some mature creatures (eggs hatched and grew)
|
||||||
|
const allCreatures = query(world, [Creature, LifeCycle]);
|
||||||
|
let hasMature = false;
|
||||||
|
for (const eid of allCreatures) {
|
||||||
|
if (LifeCycle.stage[eid] === LifeStage.Mature || LifeCycle.stage[eid] === LifeStage.Aging) {
|
||||||
|
hasMature = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Either has mature creatures or population renewed
|
||||||
|
const total = countPopulations(world).get(SpeciesId.Acidophile) ?? 0;
|
||||||
|
expect(total).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
147
tests/escalation.test.ts
Normal file
147
tests/escalation.test.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import type { RunState } from '../src/run/types';
|
||||||
|
import { RunPhase, ESCALATION_RATE } from '../src/run/types';
|
||||||
|
import { createRunState, advancePhase, updateEscalation } from '../src/run/state';
|
||||||
|
import {
|
||||||
|
getEscalationEffects,
|
||||||
|
type EscalationEffects,
|
||||||
|
} from '../src/run/escalation';
|
||||||
|
import {
|
||||||
|
createCrisisState,
|
||||||
|
applyCrisisDamage,
|
||||||
|
attemptNeutralize,
|
||||||
|
isCrisisResolved,
|
||||||
|
getCrisisPlayerDamage,
|
||||||
|
getCrisisTint,
|
||||||
|
CHEMICAL_PLAGUE,
|
||||||
|
} from '../src/run/crisis';
|
||||||
|
|
||||||
|
// ─── Escalation Effects ──────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Escalation Effects', () => {
|
||||||
|
it('at 0 escalation, all multipliers are 1.0', () => {
|
||||||
|
const fx = getEscalationEffects(0);
|
||||||
|
expect(fx.creatureSpeedMultiplier).toBe(1.0);
|
||||||
|
expect(fx.creatureAggroRange).toBe(1.0);
|
||||||
|
expect(fx.creatureAttackMultiplier).toBe(1.0);
|
||||||
|
expect(fx.reactionInstability).toBe(0);
|
||||||
|
expect(fx.environmentalDamage).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('at 0.5 escalation, effects are moderate', () => {
|
||||||
|
const fx = getEscalationEffects(0.5);
|
||||||
|
expect(fx.creatureSpeedMultiplier).toBeGreaterThan(1.0);
|
||||||
|
expect(fx.creatureSpeedMultiplier).toBeLessThan(2.0);
|
||||||
|
expect(fx.creatureAggroRange).toBeGreaterThan(1.0);
|
||||||
|
expect(fx.creatureAttackMultiplier).toBeGreaterThan(1.0);
|
||||||
|
expect(fx.reactionInstability).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('at 1.0 escalation, effects are maximal', () => {
|
||||||
|
const fx = getEscalationEffects(1.0);
|
||||||
|
expect(fx.creatureSpeedMultiplier).toBe(1.5);
|
||||||
|
expect(fx.creatureAggroRange).toBe(1.8);
|
||||||
|
expect(fx.creatureAttackMultiplier).toBe(1.6);
|
||||||
|
expect(fx.reactionInstability).toBe(0.3);
|
||||||
|
expect(fx.environmentalDamage).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escalation clamps values correctly', () => {
|
||||||
|
const fx = getEscalationEffects(2.0); // over max
|
||||||
|
expect(fx.creatureSpeedMultiplier).toBe(1.5);
|
||||||
|
|
||||||
|
const fxNeg = getEscalationEffects(-1.0); // under min
|
||||||
|
expect(fxNeg.creatureSpeedMultiplier).toBe(1.0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Crisis Damage ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Crisis Player Damage', () => {
|
||||||
|
it('no damage below 30% progress', () => {
|
||||||
|
const crisis = createCrisisState(CHEMICAL_PLAGUE);
|
||||||
|
crisis.progress = 0.2;
|
||||||
|
expect(getCrisisPlayerDamage(crisis, 1000)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('damage increases with progress above 30%', () => {
|
||||||
|
const crisis = createCrisisState(CHEMICAL_PLAGUE);
|
||||||
|
crisis.progress = 0.5;
|
||||||
|
const dmg = getCrisisPlayerDamage(crisis, 1000);
|
||||||
|
expect(dmg).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('damage scales with delta time', () => {
|
||||||
|
const crisis = createCrisisState(CHEMICAL_PLAGUE);
|
||||||
|
crisis.progress = 0.8;
|
||||||
|
const dmg1 = getCrisisPlayerDamage(crisis, 1000);
|
||||||
|
const dmg2 = getCrisisPlayerDamage(crisis, 2000);
|
||||||
|
expect(dmg2).toBeCloseTo(dmg1 * 2, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no damage when resolved', () => {
|
||||||
|
const crisis = createCrisisState(CHEMICAL_PLAGUE);
|
||||||
|
crisis.progress = 0.8;
|
||||||
|
crisis.resolved = true;
|
||||||
|
expect(getCrisisPlayerDamage(crisis, 1000)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Crisis Visual Tint', () => {
|
||||||
|
it('returns no tint at 0 progress', () => {
|
||||||
|
const crisis = createCrisisState(CHEMICAL_PLAGUE);
|
||||||
|
crisis.progress = 0;
|
||||||
|
const tint = getCrisisTint(crisis);
|
||||||
|
expect(tint.alpha).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns tint at high progress', () => {
|
||||||
|
const crisis = createCrisisState(CHEMICAL_PLAGUE);
|
||||||
|
crisis.progress = 0.8;
|
||||||
|
const tint = getCrisisTint(crisis);
|
||||||
|
expect(tint.alpha).toBeGreaterThan(0);
|
||||||
|
expect(tint.color).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no tint when resolved', () => {
|
||||||
|
const crisis = createCrisisState(CHEMICAL_PLAGUE);
|
||||||
|
crisis.progress = 0.8;
|
||||||
|
crisis.resolved = true;
|
||||||
|
const tint = getCrisisTint(crisis);
|
||||||
|
expect(tint.alpha).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Escalation-Phase Integration ────────────────────────────────
|
||||||
|
|
||||||
|
describe('Escalation Integration', () => {
|
||||||
|
let state: RunState;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state = createRunState(1, 'alchemist');
|
||||||
|
state.phase = RunPhase.Escalation;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escalation grows steadily over time', () => {
|
||||||
|
// Simulate 30 seconds
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
updateEscalation(state, 1000);
|
||||||
|
}
|
||||||
|
expect(state.escalation).toBeCloseTo(ESCALATION_RATE * 30, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('effects scale linearly with escalation', () => {
|
||||||
|
updateEscalation(state, 100_000); // ~100 seconds
|
||||||
|
const fx = getEscalationEffects(state.escalation);
|
||||||
|
expect(fx.creatureSpeedMultiplier).toBeGreaterThan(1.0);
|
||||||
|
expect(fx.creatureAggroRange).toBeGreaterThan(1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('full escalation cycle reaches crisis threshold', () => {
|
||||||
|
// Simulate until threshold
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
updateEscalation(state, 1000);
|
||||||
|
}
|
||||||
|
expect(state.escalation).toBeGreaterThanOrEqual(CHEMICAL_PLAGUE.triggerThreshold);
|
||||||
|
});
|
||||||
|
});
|
||||||
469
tests/great-cycle.test.ts
Normal file
469
tests/great-cycle.test.ts
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import type { MetaState, RunState, GreatCycleState, RunTrace } from '../src/run/types';
|
||||||
|
import {
|
||||||
|
CYCLE_THEMES,
|
||||||
|
CYCLE_THEME_NAMES,
|
||||||
|
CYCLE_THEME_NAMES_RU,
|
||||||
|
RUNS_PER_CYCLE,
|
||||||
|
RunPhase,
|
||||||
|
} from '../src/run/types';
|
||||||
|
import {
|
||||||
|
createGreatCycleState,
|
||||||
|
getCycleTheme,
|
||||||
|
createRunTrace,
|
||||||
|
recordRunAndAdvanceCycle,
|
||||||
|
performGreatRenewal,
|
||||||
|
getCycleWorldModifiers,
|
||||||
|
getTracesForBiome,
|
||||||
|
getDeathTraces,
|
||||||
|
isLastRunInCycle,
|
||||||
|
getCycleSummary,
|
||||||
|
} from '../src/run/cycle';
|
||||||
|
import { createMetaState, applyRunResults } from '../src/run/meta';
|
||||||
|
import { createRunState, recordDiscovery } from '../src/run/state';
|
||||||
|
import narrativeData from '../src/data/cycle-narrative.json';
|
||||||
|
|
||||||
|
// ─── Great Cycle Initialization ──────────────────────────────────
|
||||||
|
|
||||||
|
describe('Great Cycle Initialization', () => {
|
||||||
|
it('should create initial cycle state at cycle 1, run 1', () => {
|
||||||
|
const state = createGreatCycleState();
|
||||||
|
expect(state.cycleNumber).toBe(1);
|
||||||
|
expect(state.runInCycle).toBe(1);
|
||||||
|
expect(state.theme).toBe('awakening');
|
||||||
|
expect(state.currentCycleTraces).toEqual([]);
|
||||||
|
expect(state.previousCycleTraces).toEqual([]);
|
||||||
|
expect(state.renewalsCompleted).toBe(0);
|
||||||
|
expect(state.myceliumMaturation).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MetaState should include greatCycle on creation', () => {
|
||||||
|
const meta = createMetaState();
|
||||||
|
expect(meta.greatCycle).toBeDefined();
|
||||||
|
expect(meta.greatCycle.cycleNumber).toBe(1);
|
||||||
|
expect(meta.greatCycle.runInCycle).toBe(1);
|
||||||
|
expect(meta.greatCycle.theme).toBe('awakening');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Cycle Theme Resolution ─────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Cycle Theme Resolution', () => {
|
||||||
|
it('should have 6 predefined themes', () => {
|
||||||
|
expect(CYCLE_THEMES.length).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct theme for each cycle number', () => {
|
||||||
|
expect(getCycleTheme(1)).toBe('awakening');
|
||||||
|
expect(getCycleTheme(2)).toBe('doubt');
|
||||||
|
expect(getCycleTheme(3)).toBe('realization');
|
||||||
|
expect(getCycleTheme(4)).toBe('attempt');
|
||||||
|
expect(getCycleTheme(5)).toBe('acceptance');
|
||||||
|
expect(getCycleTheme(6)).toBe('synthesis');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cycles beyond 6 should use synthesis theme', () => {
|
||||||
|
expect(getCycleTheme(7)).toBe('synthesis');
|
||||||
|
expect(getCycleTheme(10)).toBe('synthesis');
|
||||||
|
expect(getCycleTheme(100)).toBe('synthesis');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalid cycle numbers default to awakening', () => {
|
||||||
|
expect(getCycleTheme(0)).toBe('awakening');
|
||||||
|
expect(getCycleTheme(-1)).toBe('awakening');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all themes have Russian and English names', () => {
|
||||||
|
for (const theme of CYCLE_THEMES) {
|
||||||
|
expect(CYCLE_THEME_NAMES[theme]).toBeDefined();
|
||||||
|
expect(CYCLE_THEME_NAMES[theme].length).toBeGreaterThan(0);
|
||||||
|
expect(CYCLE_THEME_NAMES_RU[theme]).toBeDefined();
|
||||||
|
expect(CYCLE_THEME_NAMES_RU[theme].length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Run Trace Recording ─────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Run Trace Recording', () => {
|
||||||
|
let meta: MetaState;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
meta = createMetaState();
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeCompletedRun(overrides?: Partial<RunState>): RunState {
|
||||||
|
const run = createRunState(1, 'alchemist', 'catalytic-wastes', 42);
|
||||||
|
run.phase = RunPhase.Resolution;
|
||||||
|
run.elapsed = 120000;
|
||||||
|
run.crisisResolved = true;
|
||||||
|
run.deathPosition = { tileX: 30, tileY: 40 };
|
||||||
|
recordDiscovery(run, 'element', 'Na');
|
||||||
|
recordDiscovery(run, 'element', 'O');
|
||||||
|
recordDiscovery(run, 'reaction', 'Na+Cl');
|
||||||
|
if (overrides) Object.assign(run, overrides);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should create a trace from completed run', () => {
|
||||||
|
const run = makeCompletedRun();
|
||||||
|
const trace = createRunTrace(run, meta.greatCycle);
|
||||||
|
expect(trace.runId).toBe(1);
|
||||||
|
expect(trace.runInCycle).toBe(1);
|
||||||
|
expect(trace.schoolId).toBe('alchemist');
|
||||||
|
expect(trace.biomeId).toBe('catalytic-wastes');
|
||||||
|
expect(trace.deathPosition).toEqual({ tileX: 30, tileY: 40 });
|
||||||
|
expect(trace.phaseReached).toBe(RunPhase.Resolution);
|
||||||
|
expect(trace.crisisResolved).toBe(true);
|
||||||
|
expect(trace.discoveryCount).toBe(3);
|
||||||
|
expect(trace.keyElements).toEqual(['Na', 'O']);
|
||||||
|
expect(trace.duration).toBe(120000);
|
||||||
|
expect(trace.worldSeed).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should limit key elements to 5', () => {
|
||||||
|
const run = makeCompletedRun();
|
||||||
|
for (const sym of ['H', 'C', 'Fe', 'Cu', 'Zn', 'Au', 'Hg']) {
|
||||||
|
recordDiscovery(run, 'element', sym);
|
||||||
|
}
|
||||||
|
const trace = createRunTrace(run, meta.greatCycle);
|
||||||
|
expect(trace.keyElements.length).toBeLessThanOrEqual(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null death position (boss victory)', () => {
|
||||||
|
const run = makeCompletedRun({ deathPosition: null });
|
||||||
|
const trace = createRunTrace(run, meta.greatCycle);
|
||||||
|
expect(trace.deathPosition).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Cycle Advancement ───────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Cycle Advancement', () => {
|
||||||
|
let meta: MetaState;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
meta = createMetaState();
|
||||||
|
});
|
||||||
|
|
||||||
|
function runAndRecord(runId: number, biome: string = 'catalytic-wastes'): boolean {
|
||||||
|
const run = createRunState(runId, 'alchemist', biome, runId * 100);
|
||||||
|
run.phase = RunPhase.Resolution;
|
||||||
|
run.elapsed = 60000;
|
||||||
|
run.deathPosition = { tileX: 10, tileY: 20 };
|
||||||
|
recordDiscovery(run, 'element', 'Na');
|
||||||
|
return applyRunResults(meta, run);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should advance runInCycle after each run', () => {
|
||||||
|
expect(meta.greatCycle.runInCycle).toBe(1);
|
||||||
|
runAndRecord(1);
|
||||||
|
expect(meta.greatCycle.runInCycle).toBe(2);
|
||||||
|
runAndRecord(2);
|
||||||
|
expect(meta.greatCycle.runInCycle).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stay in cycle 1 for first 6 runs', () => {
|
||||||
|
for (let i = 1; i <= 6; i++) {
|
||||||
|
runAndRecord(i);
|
||||||
|
expect(meta.greatCycle.cycleNumber).toBe(1);
|
||||||
|
expect(meta.greatCycle.theme).toBe('awakening');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trigger Great Renewal on 7th run', () => {
|
||||||
|
for (let i = 1; i <= 6; i++) {
|
||||||
|
const isRenewal = runAndRecord(i);
|
||||||
|
expect(isRenewal).toBe(false);
|
||||||
|
}
|
||||||
|
const isRenewal = runAndRecord(7);
|
||||||
|
expect(isRenewal).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('after renewal should advance to cycle 2', () => {
|
||||||
|
for (let i = 1; i <= 7; i++) runAndRecord(i);
|
||||||
|
expect(meta.greatCycle.cycleNumber).toBe(2);
|
||||||
|
expect(meta.greatCycle.runInCycle).toBe(1);
|
||||||
|
expect(meta.greatCycle.theme).toBe('doubt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accumulate traces within a cycle', () => {
|
||||||
|
runAndRecord(1);
|
||||||
|
runAndRecord(2);
|
||||||
|
runAndRecord(3);
|
||||||
|
expect(meta.greatCycle.currentCycleTraces.length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should move current traces to previous on renewal', () => {
|
||||||
|
for (let i = 1; i <= 7; i++) runAndRecord(i);
|
||||||
|
// After renewal: previous has 7 traces, current is empty
|
||||||
|
expect(meta.greatCycle.previousCycleTraces.length).toBe(7);
|
||||||
|
expect(meta.greatCycle.currentCycleTraces.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should track renewals completed', () => {
|
||||||
|
expect(meta.greatCycle.renewalsCompleted).toBe(0);
|
||||||
|
for (let i = 1; i <= 7; i++) runAndRecord(i);
|
||||||
|
expect(meta.greatCycle.renewalsCompleted).toBe(1);
|
||||||
|
for (let i = 8; i <= 14; i++) runAndRecord(i);
|
||||||
|
expect(meta.greatCycle.renewalsCompleted).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should increment mycelium maturation on renewal', () => {
|
||||||
|
expect(meta.greatCycle.myceliumMaturation).toBe(0);
|
||||||
|
for (let i = 1; i <= 7; i++) runAndRecord(i);
|
||||||
|
expect(meta.greatCycle.myceliumMaturation).toBeGreaterThan(0);
|
||||||
|
expect(meta.greatCycle.myceliumMaturation).toBeLessThanOrEqual(1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mycelium maturation caps at 1.0', () => {
|
||||||
|
// Simulate many cycles
|
||||||
|
for (let cycle = 0; cycle < 20; cycle++) {
|
||||||
|
for (let run = 1; run <= 7; run++) {
|
||||||
|
runAndRecord(cycle * 7 + run);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(meta.greatCycle.myceliumMaturation).toBeLessThanOrEqual(1.0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Mycelium Strengthening on Renewal ───────────────────────────
|
||||||
|
|
||||||
|
describe('Mycelium Strengthening on Renewal', () => {
|
||||||
|
it('should strengthen all Mycelium nodes during renewal', () => {
|
||||||
|
const meta = createMetaState();
|
||||||
|
// Seed some mycelium nodes
|
||||||
|
meta.mycelium.nodes = [
|
||||||
|
{ id: 'element:Na', type: 'element', knowledgeId: 'Na', depositedOnRun: 1, strength: 0.3 },
|
||||||
|
{ id: 'element:O', type: 'element', knowledgeId: 'O', depositedOnRun: 1, strength: 0.5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const oldStrengths = meta.mycelium.nodes.map(n => n.strength);
|
||||||
|
performGreatRenewal(meta);
|
||||||
|
|
||||||
|
for (let i = 0; i < meta.mycelium.nodes.length; i++) {
|
||||||
|
expect(meta.mycelium.nodes[i].strength).toBeGreaterThan(oldStrengths[i]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strength should not exceed 1.0', () => {
|
||||||
|
const meta = createMetaState();
|
||||||
|
meta.mycelium.nodes = [
|
||||||
|
{ id: 'element:Na', type: 'element', knowledgeId: 'Na', depositedOnRun: 1, strength: 0.95 },
|
||||||
|
];
|
||||||
|
performGreatRenewal(meta);
|
||||||
|
expect(meta.mycelium.nodes[0].strength).toBeLessThanOrEqual(1.0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── World Modifiers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Cycle World Modifiers', () => {
|
||||||
|
it('cycle 1 should have baseline modifiers (~1.0)', () => {
|
||||||
|
const mods = getCycleWorldModifiers(1);
|
||||||
|
expect(mods.elevationScaleMultiplier).toBeCloseTo(1.0, 1);
|
||||||
|
expect(mods.detailScaleMultiplier).toBeCloseTo(1.0, 1);
|
||||||
|
expect(mods.resourceDensityMultiplier).toBeCloseTo(1.0, 1);
|
||||||
|
expect(mods.creatureSpawnMultiplier).toBeCloseTo(1.0, 1);
|
||||||
|
expect(mods.escalationRateMultiplier).toBeCloseTo(1.0, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('higher cycles should have higher modifiers', () => {
|
||||||
|
const mod1 = getCycleWorldModifiers(1);
|
||||||
|
const mod3 = getCycleWorldModifiers(3);
|
||||||
|
const mod6 = getCycleWorldModifiers(6);
|
||||||
|
|
||||||
|
expect(mod3.elevationScaleMultiplier).toBeGreaterThan(mod1.elevationScaleMultiplier);
|
||||||
|
expect(mod6.escalationRateMultiplier).toBeGreaterThan(mod3.escalationRateMultiplier);
|
||||||
|
expect(mod6.creatureSpawnMultiplier).toBeGreaterThan(mod1.creatureSpawnMultiplier);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('modifiers should cap at cycle 6', () => {
|
||||||
|
const mod6 = getCycleWorldModifiers(6);
|
||||||
|
const mod10 = getCycleWorldModifiers(10);
|
||||||
|
|
||||||
|
expect(mod10.elevationScaleMultiplier).toBeCloseTo(mod6.elevationScaleMultiplier, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Trace Queries ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Trace Queries', () => {
|
||||||
|
let cycle: GreatCycleState;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cycle = createGreatCycleState();
|
||||||
|
cycle.currentCycleTraces = [
|
||||||
|
makeTrace(1, 'catalytic-wastes', { tileX: 10, tileY: 20 }),
|
||||||
|
makeTrace(2, 'kinetic-mountains', { tileX: 30, tileY: 40 }),
|
||||||
|
makeTrace(3, 'catalytic-wastes', null),
|
||||||
|
];
|
||||||
|
cycle.previousCycleTraces = [
|
||||||
|
makeTrace(4, 'catalytic-wastes', { tileX: 50, tileY: 60 }),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeTrace(
|
||||||
|
runId: number,
|
||||||
|
biomeId: string,
|
||||||
|
deathPos: { tileX: number; tileY: number } | null,
|
||||||
|
): RunTrace {
|
||||||
|
return {
|
||||||
|
runId,
|
||||||
|
runInCycle: runId,
|
||||||
|
schoolId: 'alchemist',
|
||||||
|
biomeId,
|
||||||
|
deathPosition: deathPos,
|
||||||
|
phaseReached: RunPhase.Resolution,
|
||||||
|
crisisResolved: false,
|
||||||
|
discoveryCount: 5,
|
||||||
|
keyElements: ['Na'],
|
||||||
|
duration: 60000,
|
||||||
|
worldSeed: runId * 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should filter traces by biome (current + previous)', () => {
|
||||||
|
const wastesTraces = getTracesForBiome(cycle, 'catalytic-wastes');
|
||||||
|
expect(wastesTraces.length).toBe(3); // 2 current + 1 previous
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty for biome with no traces', () => {
|
||||||
|
const forestTraces = getTracesForBiome(cycle, 'verdant-forests');
|
||||||
|
expect(forestTraces.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter death traces (non-null death position)', () => {
|
||||||
|
const allTraces = getTracesForBiome(cycle, 'catalytic-wastes');
|
||||||
|
const deathTraces = getDeathTraces(allTraces);
|
||||||
|
expect(deathTraces.length).toBe(2); // run 1 and run 4 have death positions
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isLastRunInCycle should detect 7th run', () => {
|
||||||
|
cycle.runInCycle = 6;
|
||||||
|
expect(isLastRunInCycle(cycle)).toBe(false);
|
||||||
|
cycle.runInCycle = 7;
|
||||||
|
expect(isLastRunInCycle(cycle)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Cycle Summary ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Cycle Summary', () => {
|
||||||
|
it('should return formatted summary', () => {
|
||||||
|
const cycle = createGreatCycleState();
|
||||||
|
cycle.runInCycle = 3;
|
||||||
|
const summary = getCycleSummary(cycle);
|
||||||
|
expect(summary.cycleNumber).toBe(1);
|
||||||
|
expect(summary.runInCycle).toBe(3);
|
||||||
|
expect(summary.totalRuns).toBe(7);
|
||||||
|
expect(summary.theme).toBe('awakening');
|
||||||
|
expect(summary.isLastRun).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should indicate last run', () => {
|
||||||
|
const cycle = createGreatCycleState();
|
||||||
|
cycle.runInCycle = 7;
|
||||||
|
const summary = getCycleSummary(cycle);
|
||||||
|
expect(summary.isLastRun).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Cycle Narrative Data ────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Cycle Narrative Data', () => {
|
||||||
|
it('should have narrative for all 6 themes', () => {
|
||||||
|
const themes = narrativeData.themes;
|
||||||
|
expect(Object.keys(themes).length).toBe(6);
|
||||||
|
expect(themes.awakening).toBeDefined();
|
||||||
|
expect(themes.doubt).toBeDefined();
|
||||||
|
expect(themes.realization).toBeDefined();
|
||||||
|
expect(themes.attempt).toBeDefined();
|
||||||
|
expect(themes.acceptance).toBeDefined();
|
||||||
|
expect(themes.synthesis).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each theme should have Russian text', () => {
|
||||||
|
for (const [, theme] of Object.entries(narrativeData.themes)) {
|
||||||
|
expect(theme.nameRu).toBeDefined();
|
||||||
|
expect(theme.nameRu.length).toBeGreaterThan(0);
|
||||||
|
expect(theme.descriptionRu).toBeDefined();
|
||||||
|
expect(theme.cradleQuoteRu).toBeDefined();
|
||||||
|
expect(theme.loreFrag.length).toBeGreaterThan(0);
|
||||||
|
for (const frag of theme.loreFrag) {
|
||||||
|
expect(frag.textRu).toBeDefined();
|
||||||
|
expect(frag.textRu.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have renewal messages', () => {
|
||||||
|
expect(narrativeData.renewalMessages.length).toBeGreaterThan(0);
|
||||||
|
for (const msg of narrativeData.renewalMessages) {
|
||||||
|
expect(msg.textRu).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have trace messages for death and discovery sites', () => {
|
||||||
|
expect(narrativeData.traceMessages.death_site.length).toBeGreaterThan(0);
|
||||||
|
expect(narrativeData.traceMessages.discovery_site.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Full Integration: 2 Complete Cycles ─────────────────────────
|
||||||
|
|
||||||
|
describe('Full Integration: 2 Complete Cycles', () => {
|
||||||
|
it('should progress through 2 cycles correctly', () => {
|
||||||
|
const meta = createMetaState();
|
||||||
|
let renewalCount = 0;
|
||||||
|
|
||||||
|
for (let i = 1; i <= 14; i++) {
|
||||||
|
const run = createRunState(i, 'alchemist', 'catalytic-wastes', i * 100);
|
||||||
|
run.phase = RunPhase.Crisis;
|
||||||
|
run.elapsed = 90000;
|
||||||
|
run.deathPosition = { tileX: i * 5, tileY: i * 3 };
|
||||||
|
recordDiscovery(run, 'element', `E${i}`);
|
||||||
|
|
||||||
|
const isRenewal = applyRunResults(meta, run);
|
||||||
|
if (isRenewal) renewalCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(renewalCount).toBe(2);
|
||||||
|
expect(meta.greatCycle.cycleNumber).toBe(3);
|
||||||
|
expect(meta.greatCycle.theme).toBe('realization');
|
||||||
|
expect(meta.greatCycle.runInCycle).toBe(1);
|
||||||
|
expect(meta.greatCycle.renewalsCompleted).toBe(2);
|
||||||
|
expect(meta.greatCycle.previousCycleTraces.length).toBe(7);
|
||||||
|
expect(meta.greatCycle.currentCycleTraces.length).toBe(0);
|
||||||
|
expect(meta.totalRuns).toBe(14);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── RunState Extended Fields ────────────────────────────────────
|
||||||
|
|
||||||
|
describe('RunState Extended Fields', () => {
|
||||||
|
it('should include biomeId and worldSeed', () => {
|
||||||
|
const run = createRunState(1, 'alchemist', 'kinetic-mountains', 12345);
|
||||||
|
expect(run.biomeId).toBe('kinetic-mountains');
|
||||||
|
expect(run.worldSeed).toBe(12345);
|
||||||
|
expect(run.deathPosition).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default biomeId to catalytic-wastes', () => {
|
||||||
|
const run = createRunState(1, 'alchemist');
|
||||||
|
expect(run.biomeId).toBe('catalytic-wastes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('RunSummary should include biomeId and cycleNumber', () => {
|
||||||
|
const meta = createMetaState();
|
||||||
|
const run = createRunState(1, 'alchemist', 'verdant-forests', 999);
|
||||||
|
run.phase = RunPhase.Exploration;
|
||||||
|
run.elapsed = 30000;
|
||||||
|
recordDiscovery(run, 'element', 'H');
|
||||||
|
applyRunResults(meta, run);
|
||||||
|
|
||||||
|
const lastSummary = meta.runHistory[meta.runHistory.length - 1];
|
||||||
|
expect(lastSummary.biomeId).toBe('verdant-forests');
|
||||||
|
expect(lastSummary.cycleNumber).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
191
tests/interaction.test.ts
Normal file
191
tests/interaction.test.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* Resource Interaction Tests — Phase 4.3
|
||||||
|
*
|
||||||
|
* Tests: resource element picking, proximity detection,
|
||||||
|
* collection into inventory, resource depletion.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { createWorld, addEntity, addComponent, query } from 'bitecs';
|
||||||
|
import { Position, Resource, PlayerTag } from '../src/ecs/components';
|
||||||
|
import { Inventory } from '../src/player/inventory';
|
||||||
|
import {
|
||||||
|
pickResourceElement,
|
||||||
|
interactionSystem,
|
||||||
|
type ResourceInfo,
|
||||||
|
type InteractionResult,
|
||||||
|
MINERAL_ELEMENTS,
|
||||||
|
GEYSER_ELEMENTS,
|
||||||
|
} from '../src/player/interaction';
|
||||||
|
|
||||||
|
// === Helpers ===
|
||||||
|
|
||||||
|
function createTestWorld() {
|
||||||
|
const world = createWorld();
|
||||||
|
const resourceData = new Map<number, ResourceInfo>();
|
||||||
|
const inventory = new Inventory();
|
||||||
|
return { world, resourceData, inventory };
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPlayer(world: ReturnType<typeof createWorld>, x: number, y: number): number {
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Position);
|
||||||
|
addComponent(world, eid, PlayerTag);
|
||||||
|
Position.x[eid] = x;
|
||||||
|
Position.y[eid] = y;
|
||||||
|
return eid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addResource(
|
||||||
|
world: ReturnType<typeof createWorld>,
|
||||||
|
resourceData: Map<number, ResourceInfo>,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
itemId: string,
|
||||||
|
quantity: number,
|
||||||
|
range = 40,
|
||||||
|
): number {
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Position);
|
||||||
|
addComponent(world, eid, Resource);
|
||||||
|
Position.x[eid] = x;
|
||||||
|
Position.y[eid] = y;
|
||||||
|
Resource.quantity[eid] = quantity;
|
||||||
|
Resource.interactRange[eid] = range;
|
||||||
|
resourceData.set(eid, { itemId, tileX: Math.floor(x / 32), tileY: Math.floor(y / 32) });
|
||||||
|
return eid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === pickResourceElement ===
|
||||||
|
|
||||||
|
describe('pickResourceElement', () => {
|
||||||
|
it('always returns element from provided list', () => {
|
||||||
|
for (let x = 0; x < 20; x++) {
|
||||||
|
for (let y = 0; y < 20; y++) {
|
||||||
|
const el = pickResourceElement(x, y, 12345, MINERAL_ELEMENTS);
|
||||||
|
expect(MINERAL_ELEMENTS).toContain(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is deterministic (same inputs → same output)', () => {
|
||||||
|
const a = pickResourceElement(5, 10, 42, MINERAL_ELEMENTS);
|
||||||
|
const b = pickResourceElement(5, 10, 42, MINERAL_ELEMENTS);
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('varies with different positions', () => {
|
||||||
|
const results = new Set<string>();
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
results.add(pickResourceElement(i, i * 7, 42, MINERAL_ELEMENTS));
|
||||||
|
}
|
||||||
|
// Should use more than one element (statistical certainty)
|
||||||
|
expect(results.size).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with geyser elements', () => {
|
||||||
|
const el = pickResourceElement(3, 7, 42, GEYSER_ELEMENTS);
|
||||||
|
expect(GEYSER_ELEMENTS).toContain(el);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Interaction System ===
|
||||||
|
|
||||||
|
describe('interactionSystem — collection', () => {
|
||||||
|
it('collects element when in range and pressing E', () => {
|
||||||
|
const { world, resourceData, inventory } = createTestWorld();
|
||||||
|
addPlayer(world, 100, 100);
|
||||||
|
addResource(world, resourceData, 120, 100, 'Fe', 5);
|
||||||
|
|
||||||
|
const result = interactionSystem(world, true, inventory, resourceData);
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.type).toBe('collected');
|
||||||
|
expect(result!.itemId).toBe('Fe');
|
||||||
|
expect(inventory.getCount('Fe')).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when E is not pressed', () => {
|
||||||
|
const { world, resourceData, inventory } = createTestWorld();
|
||||||
|
addPlayer(world, 100, 100);
|
||||||
|
addResource(world, resourceData, 120, 100, 'Fe', 5);
|
||||||
|
|
||||||
|
const result = interactionSystem(world, false, inventory, resourceData);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(inventory.getCount('Fe')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when no resources in range', () => {
|
||||||
|
const { world, resourceData, inventory } = createTestWorld();
|
||||||
|
addPlayer(world, 100, 100);
|
||||||
|
addResource(world, resourceData, 500, 500, 'Fe', 5); // far away
|
||||||
|
|
||||||
|
const result = interactionSystem(world, true, inventory, resourceData);
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.type).toBe('nothing_nearby');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks closest resource when multiple in range', () => {
|
||||||
|
const { world, resourceData, inventory } = createTestWorld();
|
||||||
|
addPlayer(world, 100, 100);
|
||||||
|
addResource(world, resourceData, 130, 100, 'Cu', 3); // 30px away
|
||||||
|
addResource(world, resourceData, 115, 100, 'Fe', 5); // 15px away (closer)
|
||||||
|
|
||||||
|
const result = interactionSystem(world, true, inventory, resourceData);
|
||||||
|
|
||||||
|
expect(result!.itemId).toBe('Fe'); // picked closer one
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decrements resource quantity on collection', () => {
|
||||||
|
const { world, resourceData, inventory } = createTestWorld();
|
||||||
|
addPlayer(world, 100, 100);
|
||||||
|
const resEid = addResource(world, resourceData, 120, 100, 'Fe', 3);
|
||||||
|
|
||||||
|
interactionSystem(world, true, inventory, resourceData);
|
||||||
|
|
||||||
|
expect(Resource.quantity[resEid]).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('interactionSystem — depletion', () => {
|
||||||
|
it('depletes resource when quantity reaches 0', () => {
|
||||||
|
const { world, resourceData, inventory } = createTestWorld();
|
||||||
|
addPlayer(world, 100, 100);
|
||||||
|
addResource(world, resourceData, 120, 100, 'Fe', 1);
|
||||||
|
|
||||||
|
const result = interactionSystem(world, true, inventory, resourceData);
|
||||||
|
|
||||||
|
expect(result!.type).toBe('depleted');
|
||||||
|
expect(result!.itemId).toBe('Fe');
|
||||||
|
expect(resourceData.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes entity from world on depletion', () => {
|
||||||
|
const { world, resourceData, inventory } = createTestWorld();
|
||||||
|
addPlayer(world, 100, 100);
|
||||||
|
addResource(world, resourceData, 120, 100, 'Fe', 1);
|
||||||
|
|
||||||
|
interactionSystem(world, true, inventory, resourceData);
|
||||||
|
|
||||||
|
const remaining = query(world, [Resource]);
|
||||||
|
expect(remaining.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('interactionSystem — inventory full', () => {
|
||||||
|
it('reports inventory_full when cannot add', () => {
|
||||||
|
const { world, resourceData } = createTestWorld();
|
||||||
|
const inventory = new Inventory(1, 1); // very small
|
||||||
|
inventory.addItem('H', 1); // fills it up
|
||||||
|
|
||||||
|
addPlayer(world, 100, 100);
|
||||||
|
addResource(world, resourceData, 120, 100, 'Fe', 5);
|
||||||
|
|
||||||
|
const result = interactionSystem(world, true, inventory, resourceData);
|
||||||
|
|
||||||
|
expect(result!.type).toBe('inventory_full');
|
||||||
|
expect(Resource.quantity[query(world, [Resource])[0]]).toBe(5); // not consumed
|
||||||
|
});
|
||||||
|
});
|
||||||
244
tests/inventory.test.ts
Normal file
244
tests/inventory.test.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
/**
|
||||||
|
* Inventory System Tests — Phase 4.2
|
||||||
|
*
|
||||||
|
* Weight-based inventory with element stacking,
|
||||||
|
* mass limits, and slot limits.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { Inventory, getItemMass } from '../src/player/inventory';
|
||||||
|
|
||||||
|
// === Item Mass Lookup ===
|
||||||
|
|
||||||
|
describe('getItemMass', () => {
|
||||||
|
it('returns atomic mass for elements', () => {
|
||||||
|
expect(getItemMass('H')).toBeCloseTo(1.008);
|
||||||
|
expect(getItemMass('Na')).toBeCloseTo(22.99);
|
||||||
|
expect(getItemMass('Fe')).toBeCloseTo(55.845);
|
||||||
|
expect(getItemMass('O')).toBeCloseTo(15.999);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns molecular mass for compounds', () => {
|
||||||
|
expect(getItemMass('NaCl')).toBeCloseTo(58.44);
|
||||||
|
expect(getItemMass('H2O')).toBeCloseTo(18.015);
|
||||||
|
expect(getItemMass('CO2')).toBeCloseTo(44.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 for unknown items', () => {
|
||||||
|
expect(getItemMass('UNKNOWN')).toBe(0);
|
||||||
|
expect(getItemMass('')).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Inventory Core ===
|
||||||
|
|
||||||
|
describe('Inventory — creation', () => {
|
||||||
|
it('starts empty', () => {
|
||||||
|
const inv = new Inventory();
|
||||||
|
expect(inv.isEmpty()).toBe(true);
|
||||||
|
expect(inv.getItems()).toEqual([]);
|
||||||
|
expect(inv.getTotalWeight()).toBe(0);
|
||||||
|
expect(inv.slotCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects custom limits', () => {
|
||||||
|
const inv = new Inventory(100, 5);
|
||||||
|
expect(inv.maxWeight).toBe(100);
|
||||||
|
expect(inv.maxSlots).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Inventory — adding items', () => {
|
||||||
|
it('adds elements', () => {
|
||||||
|
const inv = new Inventory();
|
||||||
|
const added = inv.addItem('H', 3);
|
||||||
|
expect(added).toBe(3);
|
||||||
|
expect(inv.getCount('H')).toBe(3);
|
||||||
|
expect(inv.isEmpty()).toBe(false);
|
||||||
|
expect(inv.slotCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds compounds', () => {
|
||||||
|
const inv = new Inventory();
|
||||||
|
const added = inv.addItem('NaCl', 1);
|
||||||
|
expect(added).toBe(1);
|
||||||
|
expect(inv.getCount('NaCl')).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stacks same elements', () => {
|
||||||
|
const inv = new Inventory();
|
||||||
|
inv.addItem('Na', 2);
|
||||||
|
inv.addItem('Na', 3);
|
||||||
|
expect(inv.getCount('Na')).toBe(5);
|
||||||
|
expect(inv.slotCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stacking does not consume new slot', () => {
|
||||||
|
const inv = new Inventory(10000, 3);
|
||||||
|
inv.addItem('H', 1);
|
||||||
|
inv.addItem('O', 1);
|
||||||
|
inv.addItem('Na', 1);
|
||||||
|
const added = inv.addItem('H', 5);
|
||||||
|
expect(added).toBe(5);
|
||||||
|
expect(inv.getCount('H')).toBe(6);
|
||||||
|
expect(inv.slotCount).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 for unknown items', () => {
|
||||||
|
const inv = new Inventory();
|
||||||
|
expect(inv.addItem('UNKNOWN', 1)).toBe(0);
|
||||||
|
expect(inv.isEmpty()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 for zero/negative count', () => {
|
||||||
|
const inv = new Inventory();
|
||||||
|
expect(inv.addItem('H', 0)).toBe(0);
|
||||||
|
expect(inv.addItem('H', -1)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Inventory — removing items', () => {
|
||||||
|
it('removes items', () => {
|
||||||
|
const inv = new Inventory();
|
||||||
|
inv.addItem('H', 5);
|
||||||
|
const removed = inv.removeItem('H', 3);
|
||||||
|
expect(removed).toBe(3);
|
||||||
|
expect(inv.getCount('H')).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleans up slot when count reaches zero', () => {
|
||||||
|
const inv = new Inventory();
|
||||||
|
inv.addItem('H', 3);
|
||||||
|
inv.removeItem('H', 3);
|
||||||
|
expect(inv.getCount('H')).toBe(0);
|
||||||
|
expect(inv.slotCount).toBe(0);
|
||||||
|
expect(inv.isEmpty()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes only what is available', () => {
|
||||||
|
const inv = new Inventory();
|
||||||
|
inv.addItem('H', 2);
|
||||||
|
const removed = inv.removeItem('H', 5);
|
||||||
|
expect(removed).toBe(2);
|
||||||
|
expect(inv.getCount('H')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 for non-existent item', () => {
|
||||||
|
const inv = new Inventory();
|
||||||
|
expect(inv.removeItem('H', 1)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 for zero/negative count', () => {
|
||||||
|
const inv = new Inventory();
|
||||||
|
inv.addItem('H', 5);
|
||||||
|
expect(inv.removeItem('H', 0)).toBe(0);
|
||||||
|
expect(inv.removeItem('H', -1)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Inventory — weight system', () => {
|
||||||
|
it('calculates total weight from atomic masses', () => {
|
||||||
|
const inv = new Inventory();
|
||||||
|
inv.addItem('Na', 2); // 22.99 * 2 = 45.98
|
||||||
|
expect(inv.getTotalWeight()).toBeCloseTo(22.99 * 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates mixed element + compound weight', () => {
|
||||||
|
const inv = new Inventory();
|
||||||
|
inv.addItem('Na', 1); // 22.99
|
||||||
|
inv.addItem('NaCl', 1); // 58.44
|
||||||
|
expect(inv.getTotalWeight()).toBeCloseTo(22.99 + 58.44);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('limits additions by weight', () => {
|
||||||
|
const inv = new Inventory(100); // 100 AMU limit
|
||||||
|
// Na mass = 22.99 → max 4 fit (4 * 22.99 = 91.96)
|
||||||
|
const added = inv.addItem('Na', 10);
|
||||||
|
expect(added).toBe(4);
|
||||||
|
expect(inv.getCount('Na')).toBe(4);
|
||||||
|
expect(inv.getTotalWeight()).toBeCloseTo(22.99 * 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weight frees up after removal', () => {
|
||||||
|
const inv = new Inventory(100);
|
||||||
|
inv.addItem('Na', 4); // 91.96
|
||||||
|
inv.removeItem('Na', 2); // now 45.98 used, ~54 free
|
||||||
|
const added = inv.addItem('Na', 3); // 2 more fit (45.98 + 2*22.99 = 91.96)
|
||||||
|
expect(added).toBe(2);
|
||||||
|
expect(inv.getCount('Na')).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getItemWeight returns stack weight', () => {
|
||||||
|
const inv = new Inventory();
|
||||||
|
inv.addItem('Na', 3);
|
||||||
|
expect(inv.getItemWeight('Na')).toBeCloseTo(22.99 * 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getItemWeight returns 0 for absent item', () => {
|
||||||
|
const inv = new Inventory();
|
||||||
|
expect(inv.getItemWeight('H')).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Inventory — slot limits', () => {
|
||||||
|
it('respects max slots', () => {
|
||||||
|
const inv = new Inventory(10000, 3);
|
||||||
|
inv.addItem('H', 1);
|
||||||
|
inv.addItem('O', 1);
|
||||||
|
inv.addItem('Na', 1);
|
||||||
|
const added = inv.addItem('Fe', 1); // 4th slot
|
||||||
|
expect(added).toBe(0);
|
||||||
|
expect(inv.slotCount).toBe(3);
|
||||||
|
expect(inv.hasItem('Fe')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('frees slot after full removal', () => {
|
||||||
|
const inv = new Inventory(10000, 2);
|
||||||
|
inv.addItem('H', 1);
|
||||||
|
inv.addItem('O', 1);
|
||||||
|
expect(inv.addItem('Na', 1)).toBe(0); // full
|
||||||
|
inv.removeItem('O', 1); // free a slot
|
||||||
|
expect(inv.addItem('Na', 1)).toBe(1); // now fits
|
||||||
|
expect(inv.slotCount).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Inventory — queries', () => {
|
||||||
|
it('hasItem checks against count', () => {
|
||||||
|
const inv = new Inventory();
|
||||||
|
inv.addItem('H', 3);
|
||||||
|
expect(inv.hasItem('H', 1)).toBe(true);
|
||||||
|
expect(inv.hasItem('H', 3)).toBe(true);
|
||||||
|
expect(inv.hasItem('H', 4)).toBe(false);
|
||||||
|
expect(inv.hasItem('O')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getItems returns all items', () => {
|
||||||
|
const inv = new Inventory();
|
||||||
|
inv.addItem('H', 3);
|
||||||
|
inv.addItem('Na', 2);
|
||||||
|
const items = inv.getItems();
|
||||||
|
expect(items).toHaveLength(2);
|
||||||
|
expect(items).toContainEqual({ id: 'H', count: 3 });
|
||||||
|
expect(items).toContainEqual({ id: 'Na', count: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getCount returns 0 for absent item', () => {
|
||||||
|
const inv = new Inventory();
|
||||||
|
expect(inv.getCount('H')).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Inventory — clear', () => {
|
||||||
|
it('removes everything', () => {
|
||||||
|
const inv = new Inventory();
|
||||||
|
inv.addItem('H', 3);
|
||||||
|
inv.addItem('Na', 2);
|
||||||
|
inv.addItem('NaCl', 1);
|
||||||
|
inv.clear();
|
||||||
|
expect(inv.isEmpty()).toBe(true);
|
||||||
|
expect(inv.getTotalWeight()).toBe(0);
|
||||||
|
expect(inv.slotCount).toBe(0);
|
||||||
|
expect(inv.getItems()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
537
tests/mycelium.test.ts
Normal file
537
tests/mycelium.test.ts
Normal file
@@ -0,0 +1,537 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import type { MyceliumGraphData, MetaState, RunState } from '../src/run/types';
|
||||||
|
import { RunPhase } from '../src/run/types';
|
||||||
|
import { createMetaState } from '../src/run/meta';
|
||||||
|
import {
|
||||||
|
createMyceliumGraph,
|
||||||
|
depositKnowledge,
|
||||||
|
getNode,
|
||||||
|
getEdge,
|
||||||
|
getNodesByType,
|
||||||
|
getConnectedNodes,
|
||||||
|
getGraphStats,
|
||||||
|
} from '../src/mycelium/graph';
|
||||||
|
import {
|
||||||
|
extractMemoryFlashes,
|
||||||
|
generateFlashText,
|
||||||
|
} from '../src/mycelium/knowledge';
|
||||||
|
import {
|
||||||
|
createMycosisState,
|
||||||
|
updateMycosis,
|
||||||
|
getMycosisVisuals,
|
||||||
|
} from '../src/mycelium/mycosis';
|
||||||
|
import {
|
||||||
|
getAvailableBonuses,
|
||||||
|
purchaseBonus,
|
||||||
|
canAffordBonus,
|
||||||
|
} from '../src/mycelium/shop';
|
||||||
|
import {
|
||||||
|
spawnFungalNodes,
|
||||||
|
} from '../src/mycelium/nodes';
|
||||||
|
import type { MycosisState, MemoryFlash, FungalNodeInfo } from '../src/mycelium/types';
|
||||||
|
import { MYCOSIS_CONFIG } from '../src/mycelium/types';
|
||||||
|
|
||||||
|
// ─── Test helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function createTestRunState(runId: number = 1): RunState {
|
||||||
|
return {
|
||||||
|
runId,
|
||||||
|
schoolId: 'alchemist',
|
||||||
|
phase: RunPhase.Exploration,
|
||||||
|
phaseTimer: 0,
|
||||||
|
elapsed: 60000,
|
||||||
|
escalation: 0,
|
||||||
|
crisisActive: false,
|
||||||
|
crisisResolved: false,
|
||||||
|
discoveries: {
|
||||||
|
elements: new Set(['Na', 'Cl', 'H', 'O']),
|
||||||
|
reactions: new Set(['Na+Cl']),
|
||||||
|
compounds: new Set(['NaCl']),
|
||||||
|
creatures: new Set(['crystallid']),
|
||||||
|
},
|
||||||
|
alive: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTestMeta(): MetaState {
|
||||||
|
const meta = createMetaState();
|
||||||
|
meta.totalRuns = 2;
|
||||||
|
meta.spores = 100;
|
||||||
|
meta.mycelium = createMyceliumGraph();
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mycelium Graph Tests ────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Mycelium Graph', () => {
|
||||||
|
let graph: MyceliumGraphData;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
graph = createMyceliumGraph();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create an empty graph', () => {
|
||||||
|
expect(graph.nodes).toHaveLength(0);
|
||||||
|
expect(graph.edges).toHaveLength(0);
|
||||||
|
expect(graph.totalDeposits).toBe(0);
|
||||||
|
expect(graph.totalExtractions).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deposit element knowledge from a run', () => {
|
||||||
|
const run = createTestRunState(1);
|
||||||
|
const result = depositKnowledge(graph, run);
|
||||||
|
|
||||||
|
expect(result.newNodes).toBeGreaterThan(0);
|
||||||
|
// Should have nodes for elements + reactions + compounds + creatures
|
||||||
|
expect(graph.nodes.length).toBeGreaterThanOrEqual(4);
|
||||||
|
expect(graph.totalDeposits).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create nodes for all discovery types', () => {
|
||||||
|
const run = createTestRunState(1);
|
||||||
|
depositKnowledge(graph, run);
|
||||||
|
|
||||||
|
// 4 elements (Na, Cl, H, O) + 1 reaction + 1 compound + 1 creature = 7
|
||||||
|
expect(graph.nodes).toHaveLength(7);
|
||||||
|
|
||||||
|
const elementNodes = getNodesByType(graph, 'element');
|
||||||
|
expect(elementNodes).toHaveLength(4);
|
||||||
|
|
||||||
|
const reactionNodes = getNodesByType(graph, 'reaction');
|
||||||
|
expect(reactionNodes).toHaveLength(1);
|
||||||
|
|
||||||
|
const compoundNodes = getNodesByType(graph, 'compound');
|
||||||
|
expect(compoundNodes).toHaveLength(1);
|
||||||
|
|
||||||
|
const creatureNodes = getNodesByType(graph, 'creature');
|
||||||
|
expect(creatureNodes).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create edges between related discoveries', () => {
|
||||||
|
const run = createTestRunState(1);
|
||||||
|
depositKnowledge(graph, run);
|
||||||
|
|
||||||
|
// Elements that form a reaction should be connected to the reaction
|
||||||
|
// Na + Cl → NaCl: edges Na→reaction, Cl→reaction, reaction→compound
|
||||||
|
expect(graph.edges.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should strengthen existing nodes on re-deposit', () => {
|
||||||
|
const run1 = createTestRunState(1);
|
||||||
|
depositKnowledge(graph, run1);
|
||||||
|
|
||||||
|
const naNode = getNode(graph, 'element:Na');
|
||||||
|
expect(naNode).toBeDefined();
|
||||||
|
const initialStrength = naNode!.strength;
|
||||||
|
|
||||||
|
// Second deposit with same discoveries should strengthen
|
||||||
|
const run2 = createTestRunState(2);
|
||||||
|
run2.discoveries.elements = new Set(['Na']); // Only Na
|
||||||
|
run2.discoveries.reactions = new Set();
|
||||||
|
run2.discoveries.compounds = new Set();
|
||||||
|
run2.discoveries.creatures = new Set();
|
||||||
|
const result = depositKnowledge(graph, run2);
|
||||||
|
|
||||||
|
expect(result.strengthened).toBe(1);
|
||||||
|
expect(result.newNodes).toBe(0);
|
||||||
|
|
||||||
|
const naNodeAfter = getNode(graph, 'element:Na');
|
||||||
|
expect(naNodeAfter!.strength).toBeGreaterThan(initialStrength);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cap node strength at 1.0', () => {
|
||||||
|
const run = createTestRunState(1);
|
||||||
|
run.discoveries.elements = new Set(['Na']);
|
||||||
|
run.discoveries.reactions = new Set();
|
||||||
|
run.discoveries.compounds = new Set();
|
||||||
|
run.discoveries.creatures = new Set();
|
||||||
|
|
||||||
|
// Deposit many times
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
depositKnowledge(graph, run);
|
||||||
|
}
|
||||||
|
|
||||||
|
const naNode = getNode(graph, 'element:Na');
|
||||||
|
expect(naNode!.strength).toBeLessThanOrEqual(1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should look up nodes by ID', () => {
|
||||||
|
const run = createTestRunState(1);
|
||||||
|
depositKnowledge(graph, run);
|
||||||
|
|
||||||
|
const node = getNode(graph, 'element:Na');
|
||||||
|
expect(node).toBeDefined();
|
||||||
|
expect(node!.type).toBe('element');
|
||||||
|
expect(node!.knowledgeId).toBe('Na');
|
||||||
|
|
||||||
|
const missing = getNode(graph, 'element:Au');
|
||||||
|
expect(missing).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should look up edges between nodes', () => {
|
||||||
|
const run = createTestRunState(1);
|
||||||
|
depositKnowledge(graph, run);
|
||||||
|
|
||||||
|
// There should be edges from Na and Cl to the Na+Cl reaction
|
||||||
|
const naEdge = getEdge(graph, 'element:Na', 'reaction:Na+Cl');
|
||||||
|
// Edge may exist in either direction — check both
|
||||||
|
const naEdgeReverse = getEdge(graph, 'reaction:Na+Cl', 'element:Na');
|
||||||
|
expect(naEdge ?? naEdgeReverse).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return connected nodes', () => {
|
||||||
|
const run = createTestRunState(1);
|
||||||
|
depositKnowledge(graph, run);
|
||||||
|
|
||||||
|
// Na should be connected to the Na+Cl reaction
|
||||||
|
const connected = getConnectedNodes(graph, 'element:Na');
|
||||||
|
expect(connected.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report graph statistics', () => {
|
||||||
|
const run = createTestRunState(1);
|
||||||
|
depositKnowledge(graph, run);
|
||||||
|
|
||||||
|
const stats = getGraphStats(graph);
|
||||||
|
expect(stats.nodeCount).toBe(7);
|
||||||
|
expect(stats.edgeCount).toBeGreaterThan(0);
|
||||||
|
expect(stats.totalDeposits).toBe(1);
|
||||||
|
expect(stats.averageStrength).toBeGreaterThan(0);
|
||||||
|
expect(stats.averageStrength).toBeLessThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Knowledge System Tests ──────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Knowledge System', () => {
|
||||||
|
let graph: MyceliumGraphData;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
graph = createMyceliumGraph();
|
||||||
|
const run = createTestRunState(1);
|
||||||
|
depositKnowledge(graph, run);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract memory flashes from populated graph', () => {
|
||||||
|
const flashes = extractMemoryFlashes(graph, 3);
|
||||||
|
expect(flashes.length).toBeGreaterThan(0);
|
||||||
|
expect(flashes.length).toBeLessThanOrEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty flashes from empty graph', () => {
|
||||||
|
const emptyGraph = createMyceliumGraph();
|
||||||
|
const flashes = extractMemoryFlashes(emptyGraph, 3);
|
||||||
|
expect(flashes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate flash text for element hints', () => {
|
||||||
|
const flash = generateFlashText('element', 'Na');
|
||||||
|
expect(flash.type).toBe('element_hint');
|
||||||
|
expect(flash.text.length).toBeGreaterThan(0);
|
||||||
|
expect(flash.textRu.length).toBeGreaterThan(0);
|
||||||
|
// Should contain the element name somewhere
|
||||||
|
expect(flash.text).toContain('Na');
|
||||||
|
expect(flash.textRu).toContain('Na');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate flash text for reaction hints', () => {
|
||||||
|
const flash = generateFlashText('reaction', 'Na+Cl');
|
||||||
|
expect(flash.type).toBe('reaction_hint');
|
||||||
|
expect(flash.text.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate flash text for creature hints', () => {
|
||||||
|
const flash = generateFlashText('creature', 'crystallid');
|
||||||
|
expect(flash.type).toBe('creature_hint');
|
||||||
|
expect(flash.text.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should increment extraction counter', () => {
|
||||||
|
expect(graph.totalExtractions).toBe(0);
|
||||||
|
extractMemoryFlashes(graph, 3);
|
||||||
|
expect(graph.totalExtractions).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prefer stronger nodes for flashes', () => {
|
||||||
|
// Strengthen Na by depositing multiple times
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const run = createTestRunState(i + 2);
|
||||||
|
run.discoveries.elements = new Set(['Na']);
|
||||||
|
run.discoveries.reactions = new Set();
|
||||||
|
run.discoveries.compounds = new Set();
|
||||||
|
run.discoveries.creatures = new Set();
|
||||||
|
depositKnowledge(graph, run);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract many flashes — Na should appear frequently
|
||||||
|
let naCount = 0;
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const flashes = extractMemoryFlashes(graph, 3);
|
||||||
|
for (const f of flashes) {
|
||||||
|
if (f.text.includes('Na') || f.textRu.includes('Na')) naCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Na should appear at least once in 10 rounds of 3 flashes
|
||||||
|
expect(naCount).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Mycosis Tests ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Mycosis', () => {
|
||||||
|
let state: MycosisState;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state = createMycosisState();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create initial mycosis state at zero', () => {
|
||||||
|
expect(state.level).toBe(0);
|
||||||
|
expect(state.exposure).toBe(0);
|
||||||
|
expect(state.revealing).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should increase level when near a fungal node', () => {
|
||||||
|
updateMycosis(state, 1000, true); // 1 second near a node
|
||||||
|
expect(state.level).toBeGreaterThan(0);
|
||||||
|
expect(state.exposure).toBe(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should decrease level when away from nodes', () => {
|
||||||
|
// Build up some mycosis
|
||||||
|
updateMycosis(state, 5000, true);
|
||||||
|
const peakLevel = state.level;
|
||||||
|
|
||||||
|
// Move away
|
||||||
|
updateMycosis(state, 2000, false);
|
||||||
|
expect(state.level).toBeLessThan(peakLevel);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not go below zero', () => {
|
||||||
|
updateMycosis(state, 10000, false);
|
||||||
|
expect(state.level).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not exceed max level', () => {
|
||||||
|
updateMycosis(state, 100000, true); // Very long exposure
|
||||||
|
expect(state.level).toBeLessThanOrEqual(MYCOSIS_CONFIG.maxLevel);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should start revealing at threshold', () => {
|
||||||
|
// Build up to reveal threshold
|
||||||
|
const timeToThreshold = MYCOSIS_CONFIG.revealThreshold / MYCOSIS_CONFIG.buildRate * 1000;
|
||||||
|
updateMycosis(state, timeToThreshold + 1000, true);
|
||||||
|
expect(state.revealing).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop revealing when level drops below threshold', () => {
|
||||||
|
// Build up past threshold
|
||||||
|
const timeToThreshold = MYCOSIS_CONFIG.revealThreshold / MYCOSIS_CONFIG.buildRate * 1000;
|
||||||
|
updateMycosis(state, timeToThreshold + 2000, true);
|
||||||
|
expect(state.revealing).toBe(true);
|
||||||
|
|
||||||
|
// Let it decay
|
||||||
|
const decayTime = (state.level - MYCOSIS_CONFIG.revealThreshold + 0.1) / MYCOSIS_CONFIG.decayRate * 1000;
|
||||||
|
updateMycosis(state, decayTime + 1000, false);
|
||||||
|
expect(state.revealing).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return visual parameters based on level', () => {
|
||||||
|
updateMycosis(state, 5000, true);
|
||||||
|
|
||||||
|
const visuals = getMycosisVisuals(state);
|
||||||
|
expect(visuals.tintColor).toBe(MYCOSIS_CONFIG.tintColor);
|
||||||
|
expect(visuals.tintAlpha).toBeGreaterThan(0);
|
||||||
|
expect(visuals.tintAlpha).toBeLessThanOrEqual(MYCOSIS_CONFIG.maxTintAlpha);
|
||||||
|
expect(visuals.distortionStrength).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have zero visuals when mycosis is zero', () => {
|
||||||
|
const visuals = getMycosisVisuals(state);
|
||||||
|
expect(visuals.tintAlpha).toBe(0);
|
||||||
|
expect(visuals.distortionStrength).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Spore Shop Tests ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Spore Shop', () => {
|
||||||
|
it('should list available bonuses', () => {
|
||||||
|
const bonuses = getAvailableBonuses();
|
||||||
|
expect(bonuses.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Each bonus should have required fields
|
||||||
|
for (const bonus of bonuses) {
|
||||||
|
expect(bonus.id).toBeTruthy();
|
||||||
|
expect(bonus.nameRu).toBeTruthy();
|
||||||
|
expect(bonus.cost).toBeGreaterThan(0);
|
||||||
|
expect(bonus.effect).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should check affordability correctly', () => {
|
||||||
|
const meta = createTestMeta();
|
||||||
|
meta.spores = 15;
|
||||||
|
|
||||||
|
const bonuses = getAvailableBonuses();
|
||||||
|
const cheapBonus = bonuses.find(b => b.cost <= 15);
|
||||||
|
const expensiveBonus = bonuses.find(b => b.cost > 15);
|
||||||
|
|
||||||
|
if (cheapBonus) {
|
||||||
|
expect(canAffordBonus(meta, cheapBonus.id)).toBe(true);
|
||||||
|
}
|
||||||
|
if (expensiveBonus) {
|
||||||
|
expect(canAffordBonus(meta, expensiveBonus.id)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deduct spores on purchase', () => {
|
||||||
|
const meta = createTestMeta();
|
||||||
|
const initialSpores = meta.spores;
|
||||||
|
|
||||||
|
const bonuses = getAvailableBonuses();
|
||||||
|
const bonus = bonuses[0];
|
||||||
|
const result = purchaseBonus(meta, bonus.id);
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(meta.spores).toBe(initialSpores - bonus.cost);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the purchased effect', () => {
|
||||||
|
const meta = createTestMeta();
|
||||||
|
const bonuses = getAvailableBonuses();
|
||||||
|
const bonus = bonuses.find(b => b.effect.type === 'extra_health');
|
||||||
|
expect(bonus).toBeDefined();
|
||||||
|
|
||||||
|
const result = purchaseBonus(meta, bonus!.id);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.type).toBe('extra_health');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail to purchase with insufficient spores', () => {
|
||||||
|
const meta = createTestMeta();
|
||||||
|
meta.spores = 0;
|
||||||
|
|
||||||
|
const bonuses = getAvailableBonuses();
|
||||||
|
const result = purchaseBonus(meta, bonuses[0].id);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(meta.spores).toBe(0); // unchanged
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow non-repeatable bonuses to be purchased twice', () => {
|
||||||
|
const meta = createTestMeta();
|
||||||
|
meta.spores = 200;
|
||||||
|
|
||||||
|
const bonuses = getAvailableBonuses();
|
||||||
|
const nonRepeatable = bonuses.find(b => !b.repeatable);
|
||||||
|
if (!nonRepeatable) return; // skip if all are repeatable
|
||||||
|
|
||||||
|
const result1 = purchaseBonus(meta, nonRepeatable.id);
|
||||||
|
expect(result1).not.toBeNull();
|
||||||
|
|
||||||
|
const result2 = purchaseBonus(meta, nonRepeatable.id);
|
||||||
|
expect(result2).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Fungal Node Spawning Tests ──────────────────────────────────
|
||||||
|
|
||||||
|
describe('Fungal Node Spawning', () => {
|
||||||
|
// Build a simple 20x20 grid with walkable ground
|
||||||
|
const biome = {
|
||||||
|
id: 'test',
|
||||||
|
name: 'Test',
|
||||||
|
nameRu: 'Тест',
|
||||||
|
description: '',
|
||||||
|
descriptionRu: '',
|
||||||
|
tileSize: 16,
|
||||||
|
mapWidth: 20,
|
||||||
|
mapHeight: 20,
|
||||||
|
tiles: [
|
||||||
|
{ id: 0, name: 'ground', nameRu: 'земля', color: '#555', walkable: true, damage: 0, interactive: false, resource: false },
|
||||||
|
{ id: 1, name: 'acid-deep', nameRu: 'кислота', color: '#0f0', walkable: false, damage: 5, interactive: false, resource: false },
|
||||||
|
{ id: 2, name: 'scorched-earth', nameRu: 'выжженная', color: '#333', walkable: true, damage: 0, interactive: false, resource: false },
|
||||||
|
],
|
||||||
|
generation: {
|
||||||
|
elevationScale: 0.1,
|
||||||
|
detailScale: 0.2,
|
||||||
|
elevationRules: [{ below: 0.5, tileId: 0 }],
|
||||||
|
geyserThreshold: 0.9,
|
||||||
|
mineralThreshold: 0.8,
|
||||||
|
geyserOnTile: 0,
|
||||||
|
mineralOnTiles: [0],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function createTestGrid(): number[][] {
|
||||||
|
const grid: number[][] = [];
|
||||||
|
for (let y = 0; y < 20; y++) {
|
||||||
|
const row: number[] = [];
|
||||||
|
for (let x = 0; x < 20; x++) {
|
||||||
|
// Mostly ground, some acid
|
||||||
|
row.push(x === 0 && y === 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
grid.push(row);
|
||||||
|
}
|
||||||
|
return grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should spawn fungal nodes on walkable tiles', async () => {
|
||||||
|
const { createWorld } = await import('bitecs');
|
||||||
|
const world = createWorld();
|
||||||
|
const grid = createTestGrid();
|
||||||
|
|
||||||
|
const nodeData = spawnFungalNodes(world, grid, biome, 42);
|
||||||
|
expect(nodeData.size).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not spawn on non-walkable tiles', async () => {
|
||||||
|
const { createWorld } = await import('bitecs');
|
||||||
|
const world = createWorld();
|
||||||
|
|
||||||
|
// Make grid entirely non-walkable
|
||||||
|
const grid: number[][] = [];
|
||||||
|
for (let y = 0; y < 20; y++) {
|
||||||
|
grid.push(Array(20).fill(1)); // all acid
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeData = spawnFungalNodes(world, grid, biome, 42);
|
||||||
|
expect(nodeData.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use deterministic placement based on seed', async () => {
|
||||||
|
const { createWorld } = await import('bitecs');
|
||||||
|
const grid = createTestGrid();
|
||||||
|
|
||||||
|
const world1 = createWorld();
|
||||||
|
const data1 = spawnFungalNodes(world1, grid, biome, 42);
|
||||||
|
|
||||||
|
const world2 = createWorld();
|
||||||
|
const data2 = spawnFungalNodes(world2, grid, biome, 42);
|
||||||
|
|
||||||
|
// Same seed → same positions
|
||||||
|
const positions1 = [...data1.values()].map(d => `${d.tileX},${d.tileY}`).sort();
|
||||||
|
const positions2 = [...data2.values()].map(d => `${d.tileX},${d.tileY}`).sort();
|
||||||
|
expect(positions1).toEqual(positions2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should maintain minimum spacing between nodes', async () => {
|
||||||
|
const { createWorld } = await import('bitecs');
|
||||||
|
const world = createWorld();
|
||||||
|
const grid = createTestGrid();
|
||||||
|
|
||||||
|
const nodeData = spawnFungalNodes(world, grid, biome, 42);
|
||||||
|
const positions = [...nodeData.values()].map(d => ({ x: d.tileX, y: d.tileY }));
|
||||||
|
|
||||||
|
// No two nodes should be closer than minSpacing
|
||||||
|
// (on a 20x20 grid with spacing 8, there can be only a few nodes)
|
||||||
|
for (let i = 0; i < positions.length; i++) {
|
||||||
|
for (let j = i + 1; j < positions.length; j++) {
|
||||||
|
const dx = positions[i].x - positions[j].x;
|
||||||
|
const dy = positions[i].y - positions[j].y;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
expect(dist).toBeGreaterThanOrEqual(7); // slight tolerance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
409
tests/player.test.ts
Normal file
409
tests/player.test.ts
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
/**
|
||||||
|
* Player Systems Tests — Phase 4.1
|
||||||
|
*
|
||||||
|
* Tests pure logic functions: input → velocity, tile collision,
|
||||||
|
* spawn position finding, and player entity creation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { createWorld, addEntity, addComponent, query } from 'bitecs';
|
||||||
|
import { Position, Velocity, PlayerTag, Health, SpriteRef } from '../src/ecs/components';
|
||||||
|
import { calculatePlayerVelocity, playerInputSystem } from '../src/player/input';
|
||||||
|
import { PLAYER_SPEED, PLAYER_COLLISION_RADIUS } from '../src/player/types';
|
||||||
|
import {
|
||||||
|
isTileWalkable,
|
||||||
|
isAreaWalkable,
|
||||||
|
resolveCollision,
|
||||||
|
buildWalkableSet,
|
||||||
|
tileCollisionSystem,
|
||||||
|
} from '../src/player/collision';
|
||||||
|
import { findSpawnPosition } from '../src/player/spawn';
|
||||||
|
import { createPlayerEntity } from '../src/player/factory';
|
||||||
|
import type { InputState } from '../src/player/types';
|
||||||
|
|
||||||
|
// === Helpers ===
|
||||||
|
|
||||||
|
function makeInput(moveX = 0, moveY = 0, interact = false): InputState {
|
||||||
|
return { moveX, moveY, interact };
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Input: calculatePlayerVelocity ===
|
||||||
|
|
||||||
|
describe('calculatePlayerVelocity', () => {
|
||||||
|
it('moves right when moveX = 1', () => {
|
||||||
|
const v = calculatePlayerVelocity(makeInput(1, 0), PLAYER_SPEED);
|
||||||
|
expect(v.vx).toBe(PLAYER_SPEED);
|
||||||
|
expect(v.vy).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves left when moveX = -1', () => {
|
||||||
|
const v = calculatePlayerVelocity(makeInput(-1, 0), PLAYER_SPEED);
|
||||||
|
expect(v.vx).toBe(-PLAYER_SPEED);
|
||||||
|
expect(v.vy).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves down when moveY = 1', () => {
|
||||||
|
const v = calculatePlayerVelocity(makeInput(0, 1), PLAYER_SPEED);
|
||||||
|
expect(v.vx).toBe(0);
|
||||||
|
expect(v.vy).toBe(PLAYER_SPEED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves up when moveY = -1', () => {
|
||||||
|
const v = calculatePlayerVelocity(makeInput(0, -1), PLAYER_SPEED);
|
||||||
|
expect(v.vx).toBe(0);
|
||||||
|
expect(v.vy).toBe(-PLAYER_SPEED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops when no input', () => {
|
||||||
|
const v = calculatePlayerVelocity(makeInput(0, 0), PLAYER_SPEED);
|
||||||
|
expect(v.vx).toBe(0);
|
||||||
|
expect(v.vy).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes diagonal movement to same speed', () => {
|
||||||
|
const v = calculatePlayerVelocity(makeInput(1, 1), PLAYER_SPEED);
|
||||||
|
const mag = Math.sqrt(v.vx * v.vx + v.vy * v.vy);
|
||||||
|
expect(mag).toBeCloseTo(PLAYER_SPEED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes all four diagonal directions', () => {
|
||||||
|
for (const [mx, my] of [[-1, -1], [-1, 1], [1, -1], [1, 1]]) {
|
||||||
|
const v = calculatePlayerVelocity(makeInput(mx, my), PLAYER_SPEED);
|
||||||
|
const mag = Math.sqrt(v.vx * v.vx + v.vy * v.vy);
|
||||||
|
expect(mag).toBeCloseTo(PLAYER_SPEED);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('diagonal components are equal in magnitude', () => {
|
||||||
|
const v = calculatePlayerVelocity(makeInput(1, -1), PLAYER_SPEED);
|
||||||
|
expect(Math.abs(v.vx)).toBeCloseTo(Math.abs(v.vy));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Input: playerInputSystem ===
|
||||||
|
|
||||||
|
describe('playerInputSystem', () => {
|
||||||
|
it('sets player velocity from input', () => {
|
||||||
|
const world = createWorld();
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Position);
|
||||||
|
addComponent(world, eid, Velocity);
|
||||||
|
addComponent(world, eid, PlayerTag);
|
||||||
|
|
||||||
|
playerInputSystem(world, makeInput(1, 0));
|
||||||
|
|
||||||
|
expect(Velocity.vx[eid]).toBe(PLAYER_SPEED);
|
||||||
|
expect(Velocity.vy[eid]).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not affect non-player entities', () => {
|
||||||
|
const world = createWorld();
|
||||||
|
|
||||||
|
// Player
|
||||||
|
const player = addEntity(world);
|
||||||
|
addComponent(world, player, Position);
|
||||||
|
addComponent(world, player, Velocity);
|
||||||
|
addComponent(world, player, PlayerTag);
|
||||||
|
|
||||||
|
// Non-player with existing velocity
|
||||||
|
const other = addEntity(world);
|
||||||
|
addComponent(world, other, Position);
|
||||||
|
addComponent(world, other, Velocity);
|
||||||
|
Velocity.vx[other] = 50;
|
||||||
|
Velocity.vy[other] = -30;
|
||||||
|
|
||||||
|
playerInputSystem(world, makeInput(0, 1));
|
||||||
|
|
||||||
|
expect(Velocity.vx[player]).toBe(0);
|
||||||
|
expect(Velocity.vy[player]).toBe(PLAYER_SPEED);
|
||||||
|
// Non-player unchanged
|
||||||
|
expect(Velocity.vx[other]).toBe(50);
|
||||||
|
expect(Velocity.vy[other]).toBe(-30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops player when input is zero', () => {
|
||||||
|
const world = createWorld();
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Position);
|
||||||
|
addComponent(world, eid, Velocity);
|
||||||
|
addComponent(world, eid, PlayerTag);
|
||||||
|
Velocity.vx[eid] = 999;
|
||||||
|
Velocity.vy[eid] = 999;
|
||||||
|
|
||||||
|
playerInputSystem(world, makeInput(0, 0));
|
||||||
|
|
||||||
|
expect(Velocity.vx[eid]).toBe(0);
|
||||||
|
expect(Velocity.vy[eid]).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Collision: isTileWalkable ===
|
||||||
|
|
||||||
|
describe('isTileWalkable', () => {
|
||||||
|
const grid = [[0, 0, 0], [0, 3, 0], [0, 0, 0]];
|
||||||
|
const walkable = new Set([0]);
|
||||||
|
const ts = 32;
|
||||||
|
|
||||||
|
it('returns true for walkable tile', () => {
|
||||||
|
expect(isTileWalkable(16, 16, grid, ts, walkable)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for non-walkable tile', () => {
|
||||||
|
expect(isTileWalkable(48, 48, grid, ts, walkable)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for negative coordinates', () => {
|
||||||
|
expect(isTileWalkable(-1, 16, grid, ts, walkable)).toBe(false);
|
||||||
|
expect(isTileWalkable(16, -1, grid, ts, walkable)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for out-of-bounds coordinates', () => {
|
||||||
|
expect(isTileWalkable(96, 16, grid, ts, walkable)).toBe(false);
|
||||||
|
expect(isTileWalkable(16, 96, grid, ts, walkable)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles tile edge boundaries', () => {
|
||||||
|
expect(isTileWalkable(31.9, 16, grid, ts, walkable)).toBe(true); // still tile 0
|
||||||
|
expect(isTileWalkable(32, 16, grid, ts, walkable)).toBe(true); // tile 1 column, row 0 = walkable
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Collision: isAreaWalkable ===
|
||||||
|
|
||||||
|
describe('isAreaWalkable', () => {
|
||||||
|
const grid = [[0, 0, 0], [0, 3, 0], [0, 0, 0]];
|
||||||
|
const walkable = new Set([0]);
|
||||||
|
const ts = 32;
|
||||||
|
const r = 6;
|
||||||
|
|
||||||
|
it('returns true when area is fully on walkable tiles', () => {
|
||||||
|
expect(isAreaWalkable(16, 16, r, grid, ts, walkable)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when area overlaps non-walkable tile', () => {
|
||||||
|
// (26, 26) with r=6 → corner at (32, 32) enters tile (1,1) which is non-walkable
|
||||||
|
expect(isAreaWalkable(26, 26, r, grid, ts, walkable)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when near wall but not overlapping', () => {
|
||||||
|
// (24, 16) with r=6 → rightmost point at 30, still in tile 0 column
|
||||||
|
expect(isAreaWalkable(24, 16, r, grid, ts, walkable)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Collision: resolveCollision ===
|
||||||
|
|
||||||
|
describe('resolveCollision', () => {
|
||||||
|
const ts = 32;
|
||||||
|
const r = 6;
|
||||||
|
const walkable = new Set([0]);
|
||||||
|
|
||||||
|
it('allows movement to valid position', () => {
|
||||||
|
const grid = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
|
||||||
|
const result = resolveCollision(48, 48, 16, 16, r, grid, ts, walkable);
|
||||||
|
expect(result).toEqual({ x: 48, y: 48 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks movement into non-walkable tile (slides X when Y row is clear)', () => {
|
||||||
|
// Center tile non-walkable but row 0 at X=48 is walkable → slides X
|
||||||
|
const grid = [[0, 0, 0], [0, 3, 0], [0, 0, 0]];
|
||||||
|
const result = resolveCollision(48, 48, 16, 16, r, grid, ts, walkable);
|
||||||
|
expect(result).toEqual({ x: 48, y: 16 }); // slides along X
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fully blocks when both axes lead to non-walkable', () => {
|
||||||
|
// "+" shaped wall: column 1 and row 1 are non-walkable
|
||||||
|
const grid = [[0, 3, 0], [3, 3, 3], [0, 3, 0]];
|
||||||
|
const result = resolveCollision(48, 48, 16, 16, r, grid, ts, walkable);
|
||||||
|
expect(result).toEqual({ x: 16, y: 16 }); // can't move at all
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wall-slides along X when Y is blocked', () => {
|
||||||
|
// Top row is wall, rows 1-2 walkable
|
||||||
|
const grid = [[3, 3, 3], [0, 0, 0], [0, 0, 0]];
|
||||||
|
// From (48, 48) try to go to (60, 22) — Y into wall, X along wall
|
||||||
|
// Full: blocked (y-r=16 hits row 0)
|
||||||
|
// X-only: (60, 48) → all in row 1 → valid
|
||||||
|
const result = resolveCollision(60, 22, 48, 48, r, grid, ts, walkable);
|
||||||
|
expect(result).toEqual({ x: 60, y: 48 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wall-slides along Y when X is blocked', () => {
|
||||||
|
// Left column is wall, columns 1-2 walkable
|
||||||
|
const grid = [[3, 0, 0], [3, 0, 0], [3, 0, 0]];
|
||||||
|
// From (48, 48) try to go to (22, 60) — X into wall, Y along wall
|
||||||
|
// Full: blocked (x-r=16 hits col 0)
|
||||||
|
// X-only: (22, 48) → blocked
|
||||||
|
// Y-only: (48, 60) → all in col 1 → valid
|
||||||
|
const result = resolveCollision(22, 60, 48, 48, r, grid, ts, walkable);
|
||||||
|
expect(result).toEqual({ x: 48, y: 60 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reverts completely when both axes blocked', () => {
|
||||||
|
// Only center tile walkable, rest walls
|
||||||
|
const grid = [[3, 3, 3], [3, 0, 3], [3, 3, 3]];
|
||||||
|
// From (48, 48) try to go to (80, 80) — off walkable area
|
||||||
|
const result = resolveCollision(80, 80, 48, 48, r, grid, ts, walkable);
|
||||||
|
expect(result).toEqual({ x: 48, y: 48 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks out-of-bounds movement', () => {
|
||||||
|
const grid = [[0, 0], [0, 0]];
|
||||||
|
const result = resolveCollision(-5, 16, 16, 16, r, grid, ts, walkable);
|
||||||
|
expect(result).toEqual({ x: 16, y: 16 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Collision: tileCollisionSystem ===
|
||||||
|
|
||||||
|
describe('tileCollisionSystem', () => {
|
||||||
|
it('corrects player position after invalid movement', () => {
|
||||||
|
const world = createWorld();
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Position);
|
||||||
|
addComponent(world, eid, Velocity);
|
||||||
|
addComponent(world, eid, PlayerTag);
|
||||||
|
|
||||||
|
// Player at (48, 48) with velocity that would move into wall
|
||||||
|
// After movementSystem: Position was updated. We simulate the post-movement state.
|
||||||
|
Velocity.vx[eid] = 150; // moving right
|
||||||
|
Velocity.vy[eid] = 0;
|
||||||
|
// After 0.2s movement: moved 30px right to x=78 which is in tile (2,1)
|
||||||
|
Position.x[eid] = 78;
|
||||||
|
Position.y[eid] = 48;
|
||||||
|
|
||||||
|
// Grid where tile (2,1) is non-walkable
|
||||||
|
const grid = [[0, 0, 3], [0, 0, 3], [0, 0, 3]];
|
||||||
|
const walkable = new Set([0]);
|
||||||
|
|
||||||
|
tileCollisionSystem(world, 200, grid, 32, walkable);
|
||||||
|
|
||||||
|
// Should revert X (back to 48)
|
||||||
|
expect(Position.x[eid]).toBe(48);
|
||||||
|
expect(Position.y[eid]).toBe(48);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Collision: buildWalkableSet ===
|
||||||
|
|
||||||
|
describe('buildWalkableSet', () => {
|
||||||
|
it('includes walkable tiles', () => {
|
||||||
|
const tiles = [
|
||||||
|
{ id: 0, walkable: true },
|
||||||
|
{ id: 1, walkable: true },
|
||||||
|
{ id: 3, walkable: false },
|
||||||
|
];
|
||||||
|
const set = buildWalkableSet(tiles);
|
||||||
|
expect(set.has(0)).toBe(true);
|
||||||
|
expect(set.has(1)).toBe(true);
|
||||||
|
expect(set.has(3)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty set when no walkable tiles', () => {
|
||||||
|
const tiles = [{ id: 0, walkable: false }];
|
||||||
|
const set = buildWalkableSet(tiles);
|
||||||
|
expect(set.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Spawn Position ===
|
||||||
|
|
||||||
|
describe('findSpawnPosition', () => {
|
||||||
|
const walkable = new Set([0]);
|
||||||
|
const ts = 32;
|
||||||
|
|
||||||
|
it('finds walkable spawn position', () => {
|
||||||
|
const grid = [[3, 3, 3], [3, 0, 3], [3, 3, 3]];
|
||||||
|
const pos = findSpawnPosition(grid, ts, walkable);
|
||||||
|
expect(pos).not.toBeNull();
|
||||||
|
const tx = Math.floor(pos!.x / ts);
|
||||||
|
const ty = Math.floor(pos!.y / ts);
|
||||||
|
expect(walkable.has(grid[ty][tx])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers center of map', () => {
|
||||||
|
const grid = [
|
||||||
|
[0, 0, 0, 0, 0],
|
||||||
|
[0, 0, 0, 0, 0],
|
||||||
|
[0, 0, 0, 0, 0],
|
||||||
|
[0, 0, 0, 0, 0],
|
||||||
|
[0, 0, 0, 0, 0],
|
||||||
|
];
|
||||||
|
const pos = findSpawnPosition(grid, ts, walkable);
|
||||||
|
expect(pos).not.toBeNull();
|
||||||
|
const tx = Math.floor(pos!.x / ts);
|
||||||
|
const ty = Math.floor(pos!.y / ts);
|
||||||
|
expect(tx).toBe(2);
|
||||||
|
expect(ty).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for fully non-walkable grid', () => {
|
||||||
|
const grid = [[3, 3], [3, 3]];
|
||||||
|
const pos = findSpawnPosition(grid, ts, walkable);
|
||||||
|
expect(pos).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spirals outward to find walkable tile', () => {
|
||||||
|
// Center (2,2) is non-walkable, but neighbors are
|
||||||
|
const grid = [
|
||||||
|
[0, 0, 0, 0, 0],
|
||||||
|
[0, 3, 3, 3, 0],
|
||||||
|
[0, 3, 3, 3, 0],
|
||||||
|
[0, 3, 3, 3, 0],
|
||||||
|
[0, 0, 0, 0, 0],
|
||||||
|
];
|
||||||
|
const pos = findSpawnPosition(grid, ts, walkable);
|
||||||
|
expect(pos).not.toBeNull();
|
||||||
|
// Should find one of the ring-1 tiles (first walkable on spiral)
|
||||||
|
const tx = Math.floor(pos!.x / ts);
|
||||||
|
const ty = Math.floor(pos!.y / ts);
|
||||||
|
expect(walkable.has(grid[ty][tx])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns center of tile in pixels', () => {
|
||||||
|
const grid = [[0]];
|
||||||
|
const pos = findSpawnPosition(grid, ts, walkable);
|
||||||
|
expect(pos).toEqual({ x: 16, y: 16 }); // center of tile (0,0) = (16, 16)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Player Entity Factory ===
|
||||||
|
|
||||||
|
describe('createPlayerEntity', () => {
|
||||||
|
it('creates entity with correct position', () => {
|
||||||
|
const world = createWorld();
|
||||||
|
const eid = createPlayerEntity(world, 100, 200);
|
||||||
|
expect(Position.x[eid]).toBe(100);
|
||||||
|
expect(Position.y[eid]).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates entity with health', () => {
|
||||||
|
const world = createWorld();
|
||||||
|
const eid = createPlayerEntity(world, 0, 0);
|
||||||
|
expect(Health.current[eid]).toBeGreaterThan(0);
|
||||||
|
expect(Health.max[eid]).toBeGreaterThan(0);
|
||||||
|
expect(Health.current[eid]).toBe(Health.max[eid]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates entity with sprite', () => {
|
||||||
|
const world = createWorld();
|
||||||
|
const eid = createPlayerEntity(world, 0, 0);
|
||||||
|
expect(SpriteRef.color[eid]).toBeDefined();
|
||||||
|
expect(SpriteRef.radius[eid]).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates entity queryable with PlayerTag', () => {
|
||||||
|
const world = createWorld();
|
||||||
|
const eid = createPlayerEntity(world, 50, 50);
|
||||||
|
const players = query(world, [PlayerTag]);
|
||||||
|
expect(players).toContain(eid);
|
||||||
|
expect(players.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates entity with velocity component', () => {
|
||||||
|
const world = createWorld();
|
||||||
|
const eid = createPlayerEntity(world, 0, 0);
|
||||||
|
expect(Velocity.vx[eid]).toBe(0);
|
||||||
|
expect(Velocity.vy[eid]).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
188
tests/projectile.test.ts
Normal file
188
tests/projectile.test.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
/**
|
||||||
|
* Projectile System Tests — Phase 4.5
|
||||||
|
*
|
||||||
|
* Tests: projectile creation, direction calculation,
|
||||||
|
* lifetime expiry, tile collision removal.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { createWorld, addEntity, addComponent, query } from 'bitecs';
|
||||||
|
import { Position, Velocity, Projectile, SpriteRef } from '../src/ecs/components';
|
||||||
|
import {
|
||||||
|
launchProjectile,
|
||||||
|
projectileSystem,
|
||||||
|
calculateDirection,
|
||||||
|
type ProjectileData,
|
||||||
|
PROJECTILE_SPEED,
|
||||||
|
PROJECTILE_LIFETIME,
|
||||||
|
} from '../src/player/projectile';
|
||||||
|
|
||||||
|
// === Direction Calculation ===
|
||||||
|
|
||||||
|
describe('calculateDirection', () => {
|
||||||
|
it('normalizes direction vector', () => {
|
||||||
|
const d = calculateDirection(0, 0, 100, 0);
|
||||||
|
expect(d.dx).toBeCloseTo(1);
|
||||||
|
expect(d.dy).toBeCloseTo(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles diagonal direction', () => {
|
||||||
|
const d = calculateDirection(0, 0, 100, 100);
|
||||||
|
const mag = Math.sqrt(d.dx * d.dx + d.dy * d.dy);
|
||||||
|
expect(mag).toBeCloseTo(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles negative direction', () => {
|
||||||
|
const d = calculateDirection(100, 100, 0, 0);
|
||||||
|
expect(d.dx).toBeCloseTo(-1 / Math.SQRT2);
|
||||||
|
expect(d.dy).toBeCloseTo(-1 / Math.SQRT2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns zero for same position', () => {
|
||||||
|
const d = calculateDirection(50, 50, 50, 50);
|
||||||
|
expect(d.dx).toBe(1); // default to right
|
||||||
|
expect(d.dy).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Launch Projectile ===
|
||||||
|
|
||||||
|
describe('launchProjectile', () => {
|
||||||
|
it('creates entity at source position', () => {
|
||||||
|
const world = createWorld();
|
||||||
|
const projData = new Map<number, ProjectileData>();
|
||||||
|
|
||||||
|
const eid = launchProjectile(world, projData, 100, 200, 200, 200, 'Na');
|
||||||
|
|
||||||
|
expect(Position.x[eid]).toBe(100);
|
||||||
|
expect(Position.y[eid]).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets velocity toward target', () => {
|
||||||
|
const world = createWorld();
|
||||||
|
const projData = new Map<number, ProjectileData>();
|
||||||
|
|
||||||
|
const eid = launchProjectile(world, projData, 0, 0, 100, 0, 'Na');
|
||||||
|
|
||||||
|
expect(Velocity.vx[eid]).toBeCloseTo(PROJECTILE_SPEED);
|
||||||
|
expect(Velocity.vy[eid]).toBeCloseTo(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores chemical data', () => {
|
||||||
|
const world = createWorld();
|
||||||
|
const projData = new Map<number, ProjectileData>();
|
||||||
|
|
||||||
|
const eid = launchProjectile(world, projData, 0, 0, 100, 0, 'Na');
|
||||||
|
|
||||||
|
expect(projData.get(eid)).toBeDefined();
|
||||||
|
expect(projData.get(eid)!.itemId).toBe('Na');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets lifetime', () => {
|
||||||
|
const world = createWorld();
|
||||||
|
const projData = new Map<number, ProjectileData>();
|
||||||
|
|
||||||
|
const eid = launchProjectile(world, projData, 0, 0, 100, 0, 'Na');
|
||||||
|
|
||||||
|
expect(Projectile.lifetime[eid]).toBe(PROJECTILE_LIFETIME);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets sprite with element color', () => {
|
||||||
|
const world = createWorld();
|
||||||
|
const projData = new Map<number, ProjectileData>();
|
||||||
|
|
||||||
|
const eid = launchProjectile(world, projData, 0, 0, 100, 0, 'Na');
|
||||||
|
|
||||||
|
expect(SpriteRef.radius[eid]).toBeGreaterThan(0);
|
||||||
|
expect(SpriteRef.color[eid]).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Projectile System ===
|
||||||
|
|
||||||
|
describe('projectileSystem', () => {
|
||||||
|
it('decrements lifetime', () => {
|
||||||
|
const world = createWorld();
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Position);
|
||||||
|
addComponent(world, eid, Velocity);
|
||||||
|
addComponent(world, eid, Projectile);
|
||||||
|
Position.x[eid] = 48;
|
||||||
|
Position.y[eid] = 48;
|
||||||
|
Velocity.vx[eid] = 100;
|
||||||
|
Velocity.vy[eid] = 0;
|
||||||
|
Projectile.lifetime[eid] = 1000;
|
||||||
|
|
||||||
|
const grid = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
|
||||||
|
const walkable = new Set([0]);
|
||||||
|
const projData = new Map<number, ProjectileData>();
|
||||||
|
projData.set(eid, { itemId: 'Na' });
|
||||||
|
|
||||||
|
projectileSystem(world, 100, grid, 32, walkable, projData);
|
||||||
|
|
||||||
|
expect(Projectile.lifetime[eid]).toBe(900);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes expired projectiles', () => {
|
||||||
|
const world = createWorld();
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Position);
|
||||||
|
addComponent(world, eid, Velocity);
|
||||||
|
addComponent(world, eid, Projectile);
|
||||||
|
Position.x[eid] = 48;
|
||||||
|
Position.y[eid] = 48;
|
||||||
|
Projectile.lifetime[eid] = 50;
|
||||||
|
|
||||||
|
const grid = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
|
||||||
|
const walkable = new Set([0]);
|
||||||
|
const projData = new Map<number, ProjectileData>();
|
||||||
|
projData.set(eid, { itemId: 'Na' });
|
||||||
|
|
||||||
|
projectileSystem(world, 100, grid, 32, walkable, projData);
|
||||||
|
|
||||||
|
expect(query(world, [Projectile]).length).toBe(0);
|
||||||
|
expect(projData.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes projectiles on non-walkable tile', () => {
|
||||||
|
const world = createWorld();
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Position);
|
||||||
|
addComponent(world, eid, Velocity);
|
||||||
|
addComponent(world, eid, Projectile);
|
||||||
|
// Position on non-walkable tile (1,1) = tile 3
|
||||||
|
Position.x[eid] = 48;
|
||||||
|
Position.y[eid] = 48;
|
||||||
|
Projectile.lifetime[eid] = 5000;
|
||||||
|
|
||||||
|
const grid = [[0, 0, 0], [0, 3, 0], [0, 0, 0]];
|
||||||
|
const walkable = new Set([0]);
|
||||||
|
const projData = new Map<number, ProjectileData>();
|
||||||
|
projData.set(eid, { itemId: 'Na' });
|
||||||
|
|
||||||
|
projectileSystem(world, 16, grid, 32, walkable, projData);
|
||||||
|
|
||||||
|
expect(query(world, [Projectile]).length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps valid projectiles alive', () => {
|
||||||
|
const world = createWorld();
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Position);
|
||||||
|
addComponent(world, eid, Velocity);
|
||||||
|
addComponent(world, eid, Projectile);
|
||||||
|
Position.x[eid] = 48;
|
||||||
|
Position.y[eid] = 48;
|
||||||
|
Projectile.lifetime[eid] = 5000;
|
||||||
|
|
||||||
|
const grid = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
|
||||||
|
const walkable = new Set([0]);
|
||||||
|
const projData = new Map<number, ProjectileData>();
|
||||||
|
projData.set(eid, { itemId: 'Na' });
|
||||||
|
|
||||||
|
projectileSystem(world, 16, grid, 32, walkable, projData);
|
||||||
|
|
||||||
|
expect(query(world, [Projectile]).length).toBe(1);
|
||||||
|
expect(projData.size).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
131
tests/quickslots.test.ts
Normal file
131
tests/quickslots.test.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* Quick Slots Tests — Phase 4.6
|
||||||
|
*
|
||||||
|
* Tests: slot assignment, active selection, key switching.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { QuickSlots, SLOT_COUNT } from '../src/player/quickslots';
|
||||||
|
|
||||||
|
describe('QuickSlots — creation', () => {
|
||||||
|
it('starts with all slots empty', () => {
|
||||||
|
const qs = new QuickSlots();
|
||||||
|
for (let i = 0; i < SLOT_COUNT; i++) {
|
||||||
|
expect(qs.getSlot(i)).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts with slot 0 active', () => {
|
||||||
|
const qs = new QuickSlots();
|
||||||
|
expect(qs.activeIndex).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has 4 slots', () => {
|
||||||
|
expect(SLOT_COUNT).toBe(4);
|
||||||
|
const qs = new QuickSlots();
|
||||||
|
expect(qs.getAll()).toHaveLength(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('QuickSlots — assignment', () => {
|
||||||
|
it('assigns item to slot', () => {
|
||||||
|
const qs = new QuickSlots();
|
||||||
|
qs.assign(0, 'Na');
|
||||||
|
expect(qs.getSlot(0)).toBe('Na');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces existing item in slot', () => {
|
||||||
|
const qs = new QuickSlots();
|
||||||
|
qs.assign(0, 'Na');
|
||||||
|
qs.assign(0, 'Fe');
|
||||||
|
expect(qs.getSlot(0)).toBe('Fe');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears slot with null', () => {
|
||||||
|
const qs = new QuickSlots();
|
||||||
|
qs.assign(0, 'Na');
|
||||||
|
qs.assign(0, null);
|
||||||
|
expect(qs.getSlot(0)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores invalid index', () => {
|
||||||
|
const qs = new QuickSlots();
|
||||||
|
qs.assign(-1, 'Na');
|
||||||
|
qs.assign(4, 'Na');
|
||||||
|
// No crash, no change
|
||||||
|
expect(qs.getAll()).toEqual([null, null, null, null]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('QuickSlots — active slot', () => {
|
||||||
|
it('returns active slot item', () => {
|
||||||
|
const qs = new QuickSlots();
|
||||||
|
qs.assign(0, 'Na');
|
||||||
|
expect(qs.getActive()).toBe('Na');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for empty active slot', () => {
|
||||||
|
const qs = new QuickSlots();
|
||||||
|
expect(qs.getActive()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches active slot', () => {
|
||||||
|
const qs = new QuickSlots();
|
||||||
|
qs.assign(0, 'Na');
|
||||||
|
qs.assign(1, 'Fe');
|
||||||
|
qs.assign(2, 'Cu');
|
||||||
|
|
||||||
|
qs.setActive(1);
|
||||||
|
expect(qs.activeIndex).toBe(1);
|
||||||
|
expect(qs.getActive()).toBe('Fe');
|
||||||
|
|
||||||
|
qs.setActive(2);
|
||||||
|
expect(qs.getActive()).toBe('Cu');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps active index to valid range', () => {
|
||||||
|
const qs = new QuickSlots();
|
||||||
|
qs.setActive(-1);
|
||||||
|
expect(qs.activeIndex).toBe(0);
|
||||||
|
|
||||||
|
qs.setActive(10);
|
||||||
|
expect(qs.activeIndex).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('QuickSlots — getAll', () => {
|
||||||
|
it('returns snapshot of all slots', () => {
|
||||||
|
const qs = new QuickSlots();
|
||||||
|
qs.assign(0, 'Na');
|
||||||
|
qs.assign(2, 'Fe');
|
||||||
|
expect(qs.getAll()).toEqual(['Na', null, 'Fe', null]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('QuickSlots — auto-assign', () => {
|
||||||
|
it('assigns to first empty slot', () => {
|
||||||
|
const qs = new QuickSlots();
|
||||||
|
qs.assign(0, 'Na');
|
||||||
|
const index = qs.autoAssign('Fe');
|
||||||
|
expect(index).toBe(1);
|
||||||
|
expect(qs.getSlot(1)).toBe('Fe');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns -1 when all slots full', () => {
|
||||||
|
const qs = new QuickSlots();
|
||||||
|
qs.assign(0, 'Na');
|
||||||
|
qs.assign(1, 'Fe');
|
||||||
|
qs.assign(2, 'Cu');
|
||||||
|
qs.assign(3, 'S');
|
||||||
|
const index = qs.autoAssign('H');
|
||||||
|
expect(index).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not duplicate item already in a slot', () => {
|
||||||
|
const qs = new QuickSlots();
|
||||||
|
qs.assign(0, 'Na');
|
||||||
|
const index = qs.autoAssign('Na');
|
||||||
|
expect(index).toBe(-1); // already assigned
|
||||||
|
expect(qs.getAll().filter(s => s === 'Na')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
627
tests/run-cycle.test.ts
Normal file
627
tests/run-cycle.test.ts
Normal file
@@ -0,0 +1,627 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import schoolsData from '../src/data/schools.json';
|
||||||
|
import elementsData from '../src/data/elements.json';
|
||||||
|
import type { SchoolData, RunState, MetaState, CrisisConfig } from '../src/run/types';
|
||||||
|
import {
|
||||||
|
RunPhase,
|
||||||
|
RUN_PHASE_NAMES,
|
||||||
|
BODY_COMPOSITION,
|
||||||
|
SPORE_REWARDS,
|
||||||
|
PHASE_DURATIONS,
|
||||||
|
ESCALATION_RATE,
|
||||||
|
} from '../src/run/types';
|
||||||
|
import {
|
||||||
|
createRunState,
|
||||||
|
advancePhase,
|
||||||
|
updateEscalation,
|
||||||
|
recordDiscovery,
|
||||||
|
calculateSpores,
|
||||||
|
} from '../src/run/state';
|
||||||
|
import {
|
||||||
|
createMetaState,
|
||||||
|
applyRunResults,
|
||||||
|
isSchoolUnlocked,
|
||||||
|
getCodexEntries,
|
||||||
|
getCodexCount,
|
||||||
|
checkSchoolUnlocks,
|
||||||
|
getSchoolBonuses,
|
||||||
|
} from '../src/run/meta';
|
||||||
|
import {
|
||||||
|
createCrisisState,
|
||||||
|
applyCrisisDamage,
|
||||||
|
attemptNeutralize,
|
||||||
|
isCrisisResolved,
|
||||||
|
CHEMICAL_PLAGUE,
|
||||||
|
} from '../src/run/crisis';
|
||||||
|
|
||||||
|
// ─── School Data ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('School Data', () => {
|
||||||
|
const schools = schoolsData as SchoolData[];
|
||||||
|
|
||||||
|
it('should have exactly 4 schools', () => {
|
||||||
|
expect(schools.length).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Alchemist has correct starting elements', () => {
|
||||||
|
const alchemist = schools.find(s => s.id === 'alchemist');
|
||||||
|
expect(alchemist).toBeDefined();
|
||||||
|
expect(alchemist!.startingElements).toEqual(['H', 'O', 'C', 'Na', 'S', 'Fe']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Mechanic has correct starting elements', () => {
|
||||||
|
const mechanic = schools.find(s => s.id === 'mechanic');
|
||||||
|
expect(mechanic).toBeDefined();
|
||||||
|
expect(mechanic!.startingElements).toEqual(['Fe', 'Cu', 'Sn', 'Si', 'C']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Naturalist has correct starting elements', () => {
|
||||||
|
const naturalist = schools.find(s => s.id === 'naturalist');
|
||||||
|
expect(naturalist).toBeDefined();
|
||||||
|
expect(naturalist!.startingElements).toEqual(['C', 'N', 'O', 'P', 'K']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Navigator has correct starting elements', () => {
|
||||||
|
const navigator = schools.find(s => s.id === 'navigator');
|
||||||
|
expect(navigator).toBeDefined();
|
||||||
|
expect(navigator!.startingElements).toEqual(['Si', 'Fe', 'C', 'H', 'O']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all starting elements exist in element registry', () => {
|
||||||
|
const symbols = new Set(elementsData.map(e => e.symbol));
|
||||||
|
for (const school of schools) {
|
||||||
|
for (const elem of school.startingElements) {
|
||||||
|
expect(symbols.has(elem), `Element ${elem} not found in registry`).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starting quantities match starting elements', () => {
|
||||||
|
for (const school of schools) {
|
||||||
|
for (const elem of school.startingElements) {
|
||||||
|
expect(
|
||||||
|
school.startingQuantities[elem],
|
||||||
|
`Missing quantity for ${elem} in school ${school.id}`,
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each school has bilingual fields', () => {
|
||||||
|
for (const school of schools) {
|
||||||
|
expect(school.name.length).toBeGreaterThan(0);
|
||||||
|
expect(school.nameRu.length).toBeGreaterThan(0);
|
||||||
|
expect(school.description.length).toBeGreaterThan(0);
|
||||||
|
expect(school.descriptionRu.length).toBeGreaterThan(0);
|
||||||
|
expect(school.principle.length).toBeGreaterThan(0);
|
||||||
|
expect(school.principleRu.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each school has a valid hex color', () => {
|
||||||
|
for (const school of schools) {
|
||||||
|
expect(school.color).toMatch(/^#[0-9a-fA-F]{6}$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each school has unique id', () => {
|
||||||
|
const ids = schools.map(s => s.id);
|
||||||
|
expect(new Set(ids).size).toBe(ids.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each school has unique color', () => {
|
||||||
|
const colors = schools.map(s => s.color);
|
||||||
|
expect(new Set(colors).size).toBe(colors.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each school has bonuses defined', () => {
|
||||||
|
for (const school of schools) {
|
||||||
|
expect(school.bonuses).toBeDefined();
|
||||||
|
expect(Object.keys(school.bonuses).length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('locked schools have unlock conditions', () => {
|
||||||
|
for (const school of schools) {
|
||||||
|
if (school.id === 'alchemist') {
|
||||||
|
expect(school.unlockCondition).toBeUndefined();
|
||||||
|
} else {
|
||||||
|
expect(school.unlockCondition).toBeDefined();
|
||||||
|
expect(school.unlockCondition!.threshold).toBeGreaterThan(0);
|
||||||
|
expect(school.unlockCondition!.hint.length).toBeGreaterThan(0);
|
||||||
|
expect(school.unlockCondition!.hintRu.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each school has a real scientific principle', () => {
|
||||||
|
const alchemist = schools.find(s => s.id === 'alchemist')!;
|
||||||
|
expect(alchemist.principle).toBe('Chemical Equilibrium');
|
||||||
|
|
||||||
|
const mechanic = schools.find(s => s.id === 'mechanic')!;
|
||||||
|
expect(mechanic.principle).toBe('Lever & Moment of Force');
|
||||||
|
|
||||||
|
const naturalist = schools.find(s => s.id === 'naturalist')!;
|
||||||
|
expect(naturalist.principle).toBe('Photosynthesis');
|
||||||
|
|
||||||
|
const navigator = schools.find(s => s.id === 'navigator')!;
|
||||||
|
expect(navigator.principle).toBe('Angular Measurement');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Run State ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Run State', () => {
|
||||||
|
let state: RunState;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state = createRunState(1, 'alchemist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates initial state in Awakening phase', () => {
|
||||||
|
expect(state.runId).toBe(1);
|
||||||
|
expect(state.schoolId).toBe('alchemist');
|
||||||
|
expect(state.phase).toBe(RunPhase.Awakening);
|
||||||
|
expect(state.elapsed).toBe(0);
|
||||||
|
expect(state.escalation).toBe(0);
|
||||||
|
expect(state.crisisActive).toBe(false);
|
||||||
|
expect(state.alive).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('advances phases in order', () => {
|
||||||
|
advancePhase(state);
|
||||||
|
expect(state.phase).toBe(RunPhase.Exploration);
|
||||||
|
|
||||||
|
advancePhase(state);
|
||||||
|
expect(state.phase).toBe(RunPhase.Escalation);
|
||||||
|
|
||||||
|
advancePhase(state);
|
||||||
|
expect(state.phase).toBe(RunPhase.Crisis);
|
||||||
|
|
||||||
|
advancePhase(state);
|
||||||
|
expect(state.phase).toBe(RunPhase.Resolution);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not advance past Resolution', () => {
|
||||||
|
state.phase = RunPhase.Resolution;
|
||||||
|
advancePhase(state);
|
||||||
|
expect(state.phase).toBe(RunPhase.Resolution);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets phase timer on advance', () => {
|
||||||
|
state.phaseTimer = 5000;
|
||||||
|
advancePhase(state);
|
||||||
|
expect(state.phaseTimer).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records element discovery', () => {
|
||||||
|
recordDiscovery(state, 'element', 'Na');
|
||||||
|
expect(state.discoveries.elements.has('Na')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records reaction discovery', () => {
|
||||||
|
recordDiscovery(state, 'reaction', 'Na+Cl');
|
||||||
|
expect(state.discoveries.reactions.has('Na+Cl')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records compound discovery', () => {
|
||||||
|
recordDiscovery(state, 'compound', 'NaCl');
|
||||||
|
expect(state.discoveries.compounds.has('NaCl')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records creature discovery', () => {
|
||||||
|
recordDiscovery(state, 'creature', 'crystallid');
|
||||||
|
expect(state.discoveries.creatures.has('crystallid')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not duplicate discoveries', () => {
|
||||||
|
recordDiscovery(state, 'element', 'Na');
|
||||||
|
recordDiscovery(state, 'element', 'Na');
|
||||||
|
expect(state.discoveries.elements.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates spores from discoveries', () => {
|
||||||
|
recordDiscovery(state, 'element', 'Na');
|
||||||
|
recordDiscovery(state, 'element', 'Cl');
|
||||||
|
recordDiscovery(state, 'reaction', 'Na+Cl');
|
||||||
|
recordDiscovery(state, 'compound', 'NaCl');
|
||||||
|
recordDiscovery(state, 'creature', 'crystallid');
|
||||||
|
|
||||||
|
const spores = calculateSpores(state);
|
||||||
|
const expected = 2 * SPORE_REWARDS.element
|
||||||
|
+ 1 * SPORE_REWARDS.reaction
|
||||||
|
+ 1 * SPORE_REWARDS.compound
|
||||||
|
+ 1 * SPORE_REWARDS.creature;
|
||||||
|
expect(spores).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds crisis resolved bonus to spores', () => {
|
||||||
|
recordDiscovery(state, 'element', 'Na');
|
||||||
|
state.crisisResolved = true;
|
||||||
|
const spores = calculateSpores(state);
|
||||||
|
expect(spores).toBe(SPORE_REWARDS.element + SPORE_REWARDS.crisisResolved);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Escalation ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Escalation', () => {
|
||||||
|
let state: RunState;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state = createRunState(1, 'alchemist');
|
||||||
|
state.phase = RunPhase.Escalation;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('increases escalation over time', () => {
|
||||||
|
updateEscalation(state, 1000); // 1 second
|
||||||
|
expect(state.escalation).toBeCloseTo(ESCALATION_RATE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps escalation to 1.0', () => {
|
||||||
|
updateEscalation(state, 300_000); // 5 minutes
|
||||||
|
expect(state.escalation).toBeLessThanOrEqual(1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not escalate during Exploration', () => {
|
||||||
|
state.phase = RunPhase.Exploration;
|
||||||
|
updateEscalation(state, 10_000);
|
||||||
|
expect(state.escalation).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('continues escalation during Crisis', () => {
|
||||||
|
state.phase = RunPhase.Crisis;
|
||||||
|
state.escalation = 0.5;
|
||||||
|
updateEscalation(state, 1000);
|
||||||
|
expect(state.escalation).toBeGreaterThan(0.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Meta-Progression ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Meta-Progression', () => {
|
||||||
|
let meta: MetaState;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
meta = createMetaState();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates empty meta state', () => {
|
||||||
|
expect(meta.spores).toBe(0);
|
||||||
|
expect(meta.codex).toEqual([]);
|
||||||
|
expect(meta.totalRuns).toBe(0);
|
||||||
|
expect(meta.totalDeaths).toBe(0);
|
||||||
|
expect(meta.unlockedSchools).toEqual(['alchemist']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('alchemist is unlocked by default', () => {
|
||||||
|
expect(isSchoolUnlocked(meta, 'alchemist')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('other schools are locked by default', () => {
|
||||||
|
expect(isSchoolUnlocked(meta, 'mechanic')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies run results — adds spores', () => {
|
||||||
|
const run = createRunState(1, 'alchemist');
|
||||||
|
recordDiscovery(run, 'element', 'Na');
|
||||||
|
recordDiscovery(run, 'element', 'Cl');
|
||||||
|
|
||||||
|
applyRunResults(meta, run);
|
||||||
|
expect(meta.spores).toBe(2 * SPORE_REWARDS.element);
|
||||||
|
expect(meta.totalRuns).toBe(1);
|
||||||
|
expect(meta.totalDeaths).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies run results — adds codex entries', () => {
|
||||||
|
const run = createRunState(1, 'alchemist');
|
||||||
|
recordDiscovery(run, 'element', 'Na');
|
||||||
|
recordDiscovery(run, 'reaction', 'Na+Cl');
|
||||||
|
|
||||||
|
applyRunResults(meta, run);
|
||||||
|
expect(meta.codex.length).toBe(2);
|
||||||
|
expect(getCodexEntries(meta, 'element')).toHaveLength(1);
|
||||||
|
expect(getCodexEntries(meta, 'reaction')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not duplicate codex entries across runs', () => {
|
||||||
|
const run1 = createRunState(1, 'alchemist');
|
||||||
|
recordDiscovery(run1, 'element', 'Na');
|
||||||
|
applyRunResults(meta, run1);
|
||||||
|
|
||||||
|
const run2 = createRunState(2, 'alchemist');
|
||||||
|
recordDiscovery(run2, 'element', 'Na');
|
||||||
|
applyRunResults(meta, run2);
|
||||||
|
|
||||||
|
expect(getCodexCount(meta)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accumulates spores across runs', () => {
|
||||||
|
const run1 = createRunState(1, 'alchemist');
|
||||||
|
recordDiscovery(run1, 'element', 'Na');
|
||||||
|
applyRunResults(meta, run1);
|
||||||
|
|
||||||
|
const run2 = createRunState(2, 'alchemist');
|
||||||
|
recordDiscovery(run2, 'element', 'Cl');
|
||||||
|
applyRunResults(meta, run2);
|
||||||
|
|
||||||
|
expect(meta.spores).toBe(2 * SPORE_REWARDS.element);
|
||||||
|
expect(meta.totalRuns).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores run history summaries', () => {
|
||||||
|
const run = createRunState(1, 'alchemist');
|
||||||
|
run.phase = RunPhase.Escalation;
|
||||||
|
run.elapsed = 120_000;
|
||||||
|
recordDiscovery(run, 'element', 'Na');
|
||||||
|
recordDiscovery(run, 'compound', 'NaCl');
|
||||||
|
|
||||||
|
applyRunResults(meta, run);
|
||||||
|
expect(meta.runHistory).toHaveLength(1);
|
||||||
|
expect(meta.runHistory[0].schoolId).toBe('alchemist');
|
||||||
|
expect(meta.runHistory[0].discoveries).toBe(2);
|
||||||
|
expect(meta.runHistory[0].duration).toBe(120_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Crisis: Chemical Plague ─────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Crisis: Chemical Plague', () => {
|
||||||
|
it('defines chemical plague config', () => {
|
||||||
|
expect(CHEMICAL_PLAGUE.type).toBe('chemical-plague');
|
||||||
|
expect(CHEMICAL_PLAGUE.neutralizer).toBeDefined();
|
||||||
|
expect(CHEMICAL_PLAGUE.neutralizeAmount).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates crisis state', () => {
|
||||||
|
const crisis = createCrisisState(CHEMICAL_PLAGUE);
|
||||||
|
expect(crisis.active).toBe(true);
|
||||||
|
expect(crisis.progress).toBe(0);
|
||||||
|
expect(crisis.resolved).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies crisis damage over time', () => {
|
||||||
|
const crisis = createCrisisState(CHEMICAL_PLAGUE);
|
||||||
|
applyCrisisDamage(crisis, 1000);
|
||||||
|
expect(crisis.progress).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('crisis progress clamps at 1.0', () => {
|
||||||
|
const crisis = createCrisisState(CHEMICAL_PLAGUE);
|
||||||
|
applyCrisisDamage(crisis, 999_999);
|
||||||
|
expect(crisis.progress).toBeLessThanOrEqual(1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('neutralize reduces progress', () => {
|
||||||
|
const crisis = createCrisisState(CHEMICAL_PLAGUE);
|
||||||
|
crisis.progress = 0.5;
|
||||||
|
const result = attemptNeutralize(crisis, CHEMICAL_PLAGUE.neutralizer, 1);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(crisis.progress).toBeLessThan(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wrong compound does not neutralize', () => {
|
||||||
|
const crisis = createCrisisState(CHEMICAL_PLAGUE);
|
||||||
|
crisis.progress = 0.5;
|
||||||
|
const result = attemptNeutralize(crisis, 'WrongCompound', 1);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(crisis.progress).toBe(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sufficient neutralization resolves crisis', () => {
|
||||||
|
const crisis = createCrisisState(CHEMICAL_PLAGUE);
|
||||||
|
crisis.progress = 0.1;
|
||||||
|
attemptNeutralize(crisis, CHEMICAL_PLAGUE.neutralizer, CHEMICAL_PLAGUE.neutralizeAmount);
|
||||||
|
expect(isCrisisResolved(crisis)).toBe(true);
|
||||||
|
expect(crisis.resolved).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── School Unlock System ────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('School Unlock System', () => {
|
||||||
|
let meta: MetaState;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
meta = createMetaState();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only alchemist is unlocked at start', () => {
|
||||||
|
expect(meta.unlockedSchools).toEqual(['alchemist']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mechanic unlocks after discovering 10 elements', () => {
|
||||||
|
const run = createRunState(1, 'alchemist');
|
||||||
|
for (const sym of ['H', 'O', 'C', 'Na', 'S', 'Fe', 'Cu', 'Si', 'Sn', 'N']) {
|
||||||
|
recordDiscovery(run, 'element', sym);
|
||||||
|
}
|
||||||
|
applyRunResults(meta, run);
|
||||||
|
expect(isSchoolUnlocked(meta, 'mechanic')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mechanic does NOT unlock with fewer than 10 elements', () => {
|
||||||
|
const run = createRunState(1, 'alchemist');
|
||||||
|
for (const sym of ['H', 'O', 'C', 'Na', 'S']) {
|
||||||
|
recordDiscovery(run, 'element', sym);
|
||||||
|
}
|
||||||
|
applyRunResults(meta, run);
|
||||||
|
expect(isSchoolUnlocked(meta, 'mechanic')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('naturalist unlocks after discovering 3 creatures', () => {
|
||||||
|
const run = createRunState(1, 'alchemist');
|
||||||
|
recordDiscovery(run, 'creature', 'crystallid');
|
||||||
|
recordDiscovery(run, 'creature', 'acidophile');
|
||||||
|
recordDiscovery(run, 'creature', 'reagent');
|
||||||
|
applyRunResults(meta, run);
|
||||||
|
expect(isSchoolUnlocked(meta, 'naturalist')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('naturalist does NOT unlock with fewer than 3 creatures', () => {
|
||||||
|
const run = createRunState(1, 'alchemist');
|
||||||
|
recordDiscovery(run, 'creature', 'crystallid');
|
||||||
|
recordDiscovery(run, 'creature', 'acidophile');
|
||||||
|
applyRunResults(meta, run);
|
||||||
|
expect(isSchoolUnlocked(meta, 'naturalist')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigator unlocks after 3 completed runs', () => {
|
||||||
|
for (let i = 1; i <= 3; i++) {
|
||||||
|
const run = createRunState(i, 'alchemist');
|
||||||
|
recordDiscovery(run, 'element', `elem${i}`);
|
||||||
|
applyRunResults(meta, run);
|
||||||
|
}
|
||||||
|
expect(isSchoolUnlocked(meta, 'navigator')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigator does NOT unlock with only 2 runs', () => {
|
||||||
|
for (let i = 1; i <= 2; i++) {
|
||||||
|
const run = createRunState(i, 'alchemist');
|
||||||
|
recordDiscovery(run, 'element', `elem${i}`);
|
||||||
|
applyRunResults(meta, run);
|
||||||
|
}
|
||||||
|
expect(isSchoolUnlocked(meta, 'navigator')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unlocks persist across subsequent runs', () => {
|
||||||
|
// Unlock mechanic with 10 element discoveries
|
||||||
|
const run1 = createRunState(1, 'alchemist');
|
||||||
|
for (const sym of ['H', 'O', 'C', 'Na', 'S', 'Fe', 'Cu', 'Si', 'Sn', 'N']) {
|
||||||
|
recordDiscovery(run1, 'element', sym);
|
||||||
|
}
|
||||||
|
applyRunResults(meta, run1);
|
||||||
|
expect(isSchoolUnlocked(meta, 'mechanic')).toBe(true);
|
||||||
|
|
||||||
|
// Next run with no discoveries — mechanic still unlocked
|
||||||
|
const run2 = createRunState(2, 'alchemist');
|
||||||
|
applyRunResults(meta, run2);
|
||||||
|
expect(isSchoolUnlocked(meta, 'mechanic')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multiple schools can unlock in the same run', () => {
|
||||||
|
const run = createRunState(1, 'alchemist');
|
||||||
|
// 10 elements → mechanic
|
||||||
|
for (const sym of ['H', 'O', 'C', 'Na', 'S', 'Fe', 'Cu', 'Si', 'Sn', 'N']) {
|
||||||
|
recordDiscovery(run, 'element', sym);
|
||||||
|
}
|
||||||
|
// 3 creatures → naturalist
|
||||||
|
recordDiscovery(run, 'creature', 'crystallid');
|
||||||
|
recordDiscovery(run, 'creature', 'acidophile');
|
||||||
|
recordDiscovery(run, 'creature', 'reagent');
|
||||||
|
|
||||||
|
applyRunResults(meta, run);
|
||||||
|
expect(isSchoolUnlocked(meta, 'mechanic')).toBe(true);
|
||||||
|
expect(isSchoolUnlocked(meta, 'naturalist')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cumulative codex entries across runs unlock schools', () => {
|
||||||
|
// Run 1: discover 6 elements
|
||||||
|
const run1 = createRunState(1, 'alchemist');
|
||||||
|
for (const sym of ['H', 'O', 'C', 'Na', 'S', 'Fe']) {
|
||||||
|
recordDiscovery(run1, 'element', sym);
|
||||||
|
}
|
||||||
|
applyRunResults(meta, run1);
|
||||||
|
expect(isSchoolUnlocked(meta, 'mechanic')).toBe(false);
|
||||||
|
|
||||||
|
// Run 2: discover 4 more → total 10
|
||||||
|
const run2 = createRunState(2, 'alchemist');
|
||||||
|
for (const sym of ['Cu', 'Si', 'Sn', 'N']) {
|
||||||
|
recordDiscovery(run2, 'element', sym);
|
||||||
|
}
|
||||||
|
applyRunResults(meta, run2);
|
||||||
|
expect(isSchoolUnlocked(meta, 'mechanic')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checkSchoolUnlocks can be called standalone', () => {
|
||||||
|
// Manually populate codex with 10 elements
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
meta.codex.push({ id: `elem${i}`, type: 'element', discoveredOnRun: 1 });
|
||||||
|
}
|
||||||
|
checkSchoolUnlocks(meta);
|
||||||
|
expect(isSchoolUnlocked(meta, 'mechanic')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── School Bonuses ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('School Bonuses', () => {
|
||||||
|
it('alchemist has reaction efficiency bonus', () => {
|
||||||
|
const bonus = getSchoolBonuses('alchemist');
|
||||||
|
expect(bonus.reactionEfficiency).toBe(1.25);
|
||||||
|
expect(bonus.projectileDamage).toBe(1.0);
|
||||||
|
expect(bonus.movementSpeed).toBe(1.0);
|
||||||
|
expect(bonus.creatureAggroRange).toBe(1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mechanic has projectile damage bonus', () => {
|
||||||
|
const bonus = getSchoolBonuses('mechanic');
|
||||||
|
expect(bonus.projectileDamage).toBe(1.3);
|
||||||
|
expect(bonus.movementSpeed).toBe(1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('naturalist has creature aggro reduction', () => {
|
||||||
|
const bonus = getSchoolBonuses('naturalist');
|
||||||
|
expect(bonus.creatureAggroRange).toBe(0.6);
|
||||||
|
expect(bonus.projectileDamage).toBe(1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigator has movement speed bonus', () => {
|
||||||
|
const bonus = getSchoolBonuses('navigator');
|
||||||
|
expect(bonus.movementSpeed).toBe(1.2);
|
||||||
|
expect(bonus.creatureAggroRange).toBe(1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unknown school returns all-default bonuses', () => {
|
||||||
|
const bonus = getSchoolBonuses('nonexistent');
|
||||||
|
expect(bonus.projectileDamage).toBe(1.0);
|
||||||
|
expect(bonus.movementSpeed).toBe(1.0);
|
||||||
|
expect(bonus.creatureAggroRange).toBe(1.0);
|
||||||
|
expect(bonus.reactionEfficiency).toBe(1.0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Body Composition ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Body Composition', () => {
|
||||||
|
it('fractions sum to approximately 1.0', () => {
|
||||||
|
const sum = BODY_COMPOSITION.reduce((acc, e) => acc + e.fraction, 0);
|
||||||
|
// Allow some margin since we simplified (real body has trace elements)
|
||||||
|
expect(sum).toBeGreaterThan(0.85);
|
||||||
|
expect(sum).toBeLessThanOrEqual(1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all body elements exist in element registry', () => {
|
||||||
|
const symbols = new Set(elementsData.map(e => e.symbol));
|
||||||
|
for (const entry of BODY_COMPOSITION) {
|
||||||
|
expect(symbols.has(entry.symbol), `${entry.symbol} not in registry`).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('oxygen is the largest fraction', () => {
|
||||||
|
const oxygen = BODY_COMPOSITION.find(e => e.symbol === 'O');
|
||||||
|
expect(oxygen).toBeDefined();
|
||||||
|
expect(oxygen!.fraction).toBe(0.65);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Run Phase Names ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Run Phase Names', () => {
|
||||||
|
it('has names for all phases', () => {
|
||||||
|
expect(RUN_PHASE_NAMES[RunPhase.Awakening]).toBe('Awakening');
|
||||||
|
expect(RUN_PHASE_NAMES[RunPhase.Exploration]).toBe('Exploration');
|
||||||
|
expect(RUN_PHASE_NAMES[RunPhase.Escalation]).toBe('Escalation');
|
||||||
|
expect(RUN_PHASE_NAMES[RunPhase.Crisis]).toBe('Crisis');
|
||||||
|
expect(RUN_PHASE_NAMES[RunPhase.Resolution]).toBe('Resolution');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('phases are numbered 0-4', () => {
|
||||||
|
expect(RunPhase.Awakening).toBe(0);
|
||||||
|
expect(RunPhase.Resolution).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('phase durations are defined', () => {
|
||||||
|
expect(PHASE_DURATIONS[RunPhase.Exploration]).toBeGreaterThan(0);
|
||||||
|
expect(PHASE_DURATIONS[RunPhase.Escalation]).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
73
tests/ui.test.ts
Normal file
73
tests/ui.test.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Tests for UIScene helper functions and HUD state contract
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { ElementRegistry } from '../src/chemistry/elements';
|
||||||
|
import { CompoundRegistry } from '../src/chemistry/compounds';
|
||||||
|
import { QuickSlots, SLOT_COUNT } from '../src/player/quickslots';
|
||||||
|
import { Inventory } from '../src/player/inventory';
|
||||||
|
import { Health } from '../src/ecs/components';
|
||||||
|
|
||||||
|
/** Mirror of UIScene's getItemDisplayColor for testability */
|
||||||
|
function getItemDisplayColor(itemId: string): number {
|
||||||
|
const el = ElementRegistry.getBySymbol(itemId);
|
||||||
|
if (el) return parseInt(el.color.replace('#', ''), 16);
|
||||||
|
const comp = CompoundRegistry.getById(itemId);
|
||||||
|
if (comp) return parseInt(comp.color.replace('#', ''), 16);
|
||||||
|
return 0xffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirror of UIScene's getItemDisplayName for testability */
|
||||||
|
function getItemDisplayName(itemId: string): string {
|
||||||
|
const el = ElementRegistry.getBySymbol(itemId);
|
||||||
|
if (el) return el.symbol;
|
||||||
|
const comp = CompoundRegistry.getById(itemId);
|
||||||
|
if (comp) return comp.formula;
|
||||||
|
return itemId;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HUD display helpers', () => {
|
||||||
|
it('getItemDisplayColor returns correct color for element', () => {
|
||||||
|
const color = getItemDisplayColor('Fe');
|
||||||
|
expect(color).toBeGreaterThan(0);
|
||||||
|
expect(color).not.toBe(0xffffff); // Not the fallback
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getItemDisplayColor returns fallback for unknown item', () => {
|
||||||
|
expect(getItemDisplayColor('totally_unknown')).toBe(0xffffff);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getItemDisplayName returns symbol for element', () => {
|
||||||
|
expect(getItemDisplayName('Fe')).toBe('Fe');
|
||||||
|
expect(getItemDisplayName('Na')).toBe('Na');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getItemDisplayName returns formula for compound', () => {
|
||||||
|
const name = getItemDisplayName('H2O');
|
||||||
|
expect(name).toBe('H₂O');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getItemDisplayName returns raw id for unknown', () => {
|
||||||
|
expect(getItemDisplayName('xyz')).toBe('xyz');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HUD registry state contract', () => {
|
||||||
|
it('QuickSlots.getAll returns array of length SLOT_COUNT', () => {
|
||||||
|
const qs = new QuickSlots();
|
||||||
|
expect(qs.getAll()).toHaveLength(SLOT_COUNT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Inventory provides required state fields', () => {
|
||||||
|
const inv = new Inventory(500, 20);
|
||||||
|
expect(typeof inv.getTotalWeight()).toBe('number');
|
||||||
|
expect(typeof inv.maxWeight).toBe('number');
|
||||||
|
expect(typeof inv.slotCount).toBe('number');
|
||||||
|
expect(Array.isArray(inv.getItems())).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Health component arrays are accessible', () => {
|
||||||
|
expect(Array.isArray(Health.current)).toBe(true);
|
||||||
|
expect(Array.isArray(Health.max)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
222
tests/world-traces.test.ts
Normal file
222
tests/world-traces.test.ts
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { createWorld } from 'bitecs';
|
||||||
|
import type { GreatCycleState, RunTrace } from '../src/run/types';
|
||||||
|
import { RunPhase } from '../src/run/types';
|
||||||
|
import { createGreatCycleState } from '../src/run/cycle';
|
||||||
|
import { Position, SpriteRef, WorldTrace, TraceType } from '../src/ecs/components';
|
||||||
|
import { spawnWorldTraces, updateTraceGlow, type WorldTraceInfo } from '../src/world/traces';
|
||||||
|
import biomeData from '../src/data/biomes.json';
|
||||||
|
import type { BiomeData } from '../src/world/types';
|
||||||
|
|
||||||
|
const biomes = biomeData as BiomeData[];
|
||||||
|
const catalyticWastes = biomes.find(b => b.id === 'catalytic-wastes')!;
|
||||||
|
|
||||||
|
function makeTrace(
|
||||||
|
runId: number,
|
||||||
|
biomeId: string,
|
||||||
|
deathPos: { tileX: number; tileY: number } | null,
|
||||||
|
discoveryCount: number = 5,
|
||||||
|
): RunTrace {
|
||||||
|
return {
|
||||||
|
runId,
|
||||||
|
runInCycle: runId,
|
||||||
|
schoolId: 'alchemist',
|
||||||
|
biomeId,
|
||||||
|
deathPosition: deathPos,
|
||||||
|
phaseReached: RunPhase.Resolution,
|
||||||
|
crisisResolved: false,
|
||||||
|
discoveryCount,
|
||||||
|
keyElements: ['Na', 'O'],
|
||||||
|
duration: 60000,
|
||||||
|
worldSeed: runId * 1000 + 42,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── World Trace Spawning ────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('World Trace Spawning', () => {
|
||||||
|
let world: ReturnType<typeof createWorld>;
|
||||||
|
let cycle: GreatCycleState;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = createWorld();
|
||||||
|
cycle = createGreatCycleState();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty for no traces', () => {
|
||||||
|
const result = spawnWorldTraces(world, cycle, 'catalytic-wastes', catalyticWastes);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should spawn death site markers', () => {
|
||||||
|
cycle.currentCycleTraces = [
|
||||||
|
makeTrace(1, 'catalytic-wastes', { tileX: 20, tileY: 30 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = spawnWorldTraces(world, cycle, 'catalytic-wastes', catalyticWastes);
|
||||||
|
expect(result.length).toBe(1);
|
||||||
|
expect(result[0].traceType).toBe('death_site');
|
||||||
|
expect(result[0].sourceRunId).toBe(1);
|
||||||
|
expect(result[0].tileX).toBe(20);
|
||||||
|
expect(result[0].tileY).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('death site entities should have correct ECS components', () => {
|
||||||
|
cycle.currentCycleTraces = [
|
||||||
|
makeTrace(1, 'catalytic-wastes', { tileX: 10, tileY: 15 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = spawnWorldTraces(world, cycle, 'catalytic-wastes', catalyticWastes);
|
||||||
|
const eid = result[0].eid;
|
||||||
|
|
||||||
|
// Position at tile center
|
||||||
|
const tileSize = catalyticWastes.tileSize;
|
||||||
|
expect(Position.x[eid]).toBe(10 * tileSize + tileSize / 2);
|
||||||
|
expect(Position.y[eid]).toBe(15 * tileSize + tileSize / 2);
|
||||||
|
|
||||||
|
// Sprite
|
||||||
|
expect(SpriteRef.color[eid]).toBe(0x884444); // dark red for death site
|
||||||
|
expect(SpriteRef.radius[eid]).toBe(6);
|
||||||
|
|
||||||
|
// Trace component
|
||||||
|
expect(WorldTrace.traceType[eid]).toBe(TraceType.DeathSite);
|
||||||
|
expect(WorldTrace.sourceRunId[eid]).toBe(1);
|
||||||
|
expect(WorldTrace.interactRange[eid]).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should spawn discovery markers for runs without death sites', () => {
|
||||||
|
cycle.currentCycleTraces = [
|
||||||
|
makeTrace(1, 'catalytic-wastes', null, 5), // no death pos, 5 discoveries
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = spawnWorldTraces(world, cycle, 'catalytic-wastes', catalyticWastes);
|
||||||
|
expect(result.length).toBe(1);
|
||||||
|
expect(result[0].traceType).toBe('discovery_site');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT spawn discovery markers for low discovery count', () => {
|
||||||
|
cycle.currentCycleTraces = [
|
||||||
|
makeTrace(1, 'catalytic-wastes', null, 2), // only 2 discoveries (< 3 threshold)
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = spawnWorldTraces(world, cycle, 'catalytic-wastes', catalyticWastes);
|
||||||
|
expect(result.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prefer death marker over discovery marker for same run', () => {
|
||||||
|
// Run with both death position and discoveries
|
||||||
|
cycle.currentCycleTraces = [
|
||||||
|
makeTrace(1, 'catalytic-wastes', { tileX: 20, tileY: 30 }, 10),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = spawnWorldTraces(world, cycle, 'catalytic-wastes', catalyticWastes);
|
||||||
|
// Should only have death_site, not both
|
||||||
|
expect(result.length).toBe(1);
|
||||||
|
expect(result[0].traceType).toBe('death_site');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include traces from previous cycle', () => {
|
||||||
|
cycle.previousCycleTraces = [
|
||||||
|
makeTrace(5, 'catalytic-wastes', { tileX: 40, tileY: 50 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = spawnWorldTraces(world, cycle, 'catalytic-wastes', catalyticWastes);
|
||||||
|
expect(result.length).toBe(1);
|
||||||
|
expect(result[0].sourceRunId).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by biome', () => {
|
||||||
|
cycle.currentCycleTraces = [
|
||||||
|
makeTrace(1, 'catalytic-wastes', { tileX: 10, tileY: 20 }),
|
||||||
|
makeTrace(2, 'kinetic-mountains', { tileX: 30, tileY: 40 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = spawnWorldTraces(world, cycle, 'catalytic-wastes', catalyticWastes);
|
||||||
|
expect(result.length).toBe(1);
|
||||||
|
expect(result[0].sourceRunId).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clamp death positions to map bounds', () => {
|
||||||
|
cycle.currentCycleTraces = [
|
||||||
|
makeTrace(1, 'catalytic-wastes', { tileX: 9999, tileY: 9999 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = spawnWorldTraces(world, cycle, 'catalytic-wastes', catalyticWastes);
|
||||||
|
expect(result[0].tileX).toBeLessThan(catalyticWastes.mapWidth);
|
||||||
|
expect(result[0].tileY).toBeLessThan(catalyticWastes.mapHeight);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple traces from multiple runs', () => {
|
||||||
|
cycle.currentCycleTraces = [
|
||||||
|
makeTrace(1, 'catalytic-wastes', { tileX: 10, tileY: 10 }),
|
||||||
|
makeTrace(2, 'catalytic-wastes', { tileX: 30, tileY: 30 }),
|
||||||
|
makeTrace(3, 'catalytic-wastes', null, 8), // discovery only
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = spawnWorldTraces(world, cycle, 'catalytic-wastes', catalyticWastes);
|
||||||
|
expect(result.length).toBe(3);
|
||||||
|
|
||||||
|
const deathSites = result.filter(r => r.traceType === 'death_site');
|
||||||
|
const discoverySites = result.filter(r => r.traceType === 'discovery_site');
|
||||||
|
expect(deathSites.length).toBe(2);
|
||||||
|
expect(discoverySites.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Trace Glow Animation ────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Trace Glow Animation', () => {
|
||||||
|
it('should advance glow phase over time', () => {
|
||||||
|
const world = createWorld();
|
||||||
|
const cycle = createGreatCycleState();
|
||||||
|
cycle.currentCycleTraces = [
|
||||||
|
makeTrace(1, 'catalytic-wastes', { tileX: 20, tileY: 30 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = spawnWorldTraces(world, cycle, 'catalytic-wastes', catalyticWastes);
|
||||||
|
const initialPhase = WorldTrace.glowPhase[result[0].eid];
|
||||||
|
|
||||||
|
updateTraceGlow(result, 1000); // 1 second
|
||||||
|
const newPhase = WorldTrace.glowPhase[result[0].eid];
|
||||||
|
|
||||||
|
expect(newPhase).toBeGreaterThan(initialPhase);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Discovery Site Position Determinism ─────────────────────────
|
||||||
|
|
||||||
|
describe('Discovery Site Position Determinism', () => {
|
||||||
|
it('same seed should produce same position', () => {
|
||||||
|
const world1 = createWorld();
|
||||||
|
const world2 = createWorld();
|
||||||
|
const cycle1 = createGreatCycleState();
|
||||||
|
const cycle2 = createGreatCycleState();
|
||||||
|
|
||||||
|
const trace = makeTrace(1, 'catalytic-wastes', null, 5);
|
||||||
|
cycle1.currentCycleTraces = [trace];
|
||||||
|
cycle2.currentCycleTraces = [{ ...trace }]; // same data
|
||||||
|
|
||||||
|
const result1 = spawnWorldTraces(world1, cycle1, 'catalytic-wastes', catalyticWastes);
|
||||||
|
const result2 = spawnWorldTraces(world2, cycle2, 'catalytic-wastes', catalyticWastes);
|
||||||
|
|
||||||
|
expect(result1[0].tileX).toBe(result2[0].tileX);
|
||||||
|
expect(result1[0].tileY).toBe(result2[0].tileY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('different seeds should produce different positions', () => {
|
||||||
|
const world1 = createWorld();
|
||||||
|
const world2 = createWorld();
|
||||||
|
const cycle1 = createGreatCycleState();
|
||||||
|
const cycle2 = createGreatCycleState();
|
||||||
|
|
||||||
|
cycle1.currentCycleTraces = [makeTrace(1, 'catalytic-wastes', null, 5)];
|
||||||
|
cycle2.currentCycleTraces = [makeTrace(99, 'catalytic-wastes', null, 5)]; // different seed
|
||||||
|
|
||||||
|
const result1 = spawnWorldTraces(world1, cycle1, 'catalytic-wastes', catalyticWastes);
|
||||||
|
const result2 = spawnWorldTraces(world2, cycle2, 'catalytic-wastes', catalyticWastes);
|
||||||
|
|
||||||
|
// Very unlikely to be at the same spot with different seeds
|
||||||
|
const sameSpot = result1[0].tileX === result2[0].tileX && result1[0].tileY === result2[0].tileY;
|
||||||
|
expect(sameSpot).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,8 +4,9 @@ import { createSeededNoise, sampleNoise } from '../src/world/noise';
|
|||||||
import { generateWorld } from '../src/world/generator';
|
import { generateWorld } from '../src/world/generator';
|
||||||
import type { BiomeData } from '../src/world/types';
|
import type { BiomeData } from '../src/world/types';
|
||||||
|
|
||||||
|
const allBiomes = biomeDataArray as BiomeData[];
|
||||||
// Load the first biome — structural compatibility with BiomeData
|
// Load the first biome — structural compatibility with BiomeData
|
||||||
const biome = biomeDataArray[0] as BiomeData;
|
const biome = allBiomes[0];
|
||||||
|
|
||||||
// ─── Noise ──────────────────────────────────────────────────────
|
// ─── Noise ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -181,3 +182,139 @@ describe('World Generation', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Multi-Biome Support (Phase 9) ──────────────────────────────
|
||||||
|
|
||||||
|
describe('Multi-Biome Data', () => {
|
||||||
|
it('has 3 biomes loaded', () => {
|
||||||
|
expect(allBiomes).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each biome has a unique id', () => {
|
||||||
|
const ids = allBiomes.map(b => b.id);
|
||||||
|
expect(new Set(ids).size).toBe(3);
|
||||||
|
expect(ids).toContain('catalytic-wastes');
|
||||||
|
expect(ids).toContain('kinetic-mountains');
|
||||||
|
expect(ids).toContain('verdant-forests');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each biome has 8 tile types with sequential IDs', () => {
|
||||||
|
for (const b of allBiomes) {
|
||||||
|
expect(b.tiles).toHaveLength(8);
|
||||||
|
b.tiles.forEach((tile, index) => {
|
||||||
|
expect(tile.id, `${b.id}: tile ${index}`).toBe(index);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each biome has an interactive tile and a resource tile', () => {
|
||||||
|
for (const b of allBiomes) {
|
||||||
|
const interactive = b.tiles.find(t => t.interactive);
|
||||||
|
const resource = b.tiles.find(t => t.resource);
|
||||||
|
expect(interactive, `${b.id}: no interactive tile`).toBeDefined();
|
||||||
|
expect(resource, `${b.id}: no resource tile`).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each biome has valid elevation rules covering [0, 1]', () => {
|
||||||
|
for (const b of allBiomes) {
|
||||||
|
const rules = b.generation.elevationRules;
|
||||||
|
expect(rules.length).toBeGreaterThan(0);
|
||||||
|
expect(rules[rules.length - 1].below).toBe(1);
|
||||||
|
const validIds = new Set(b.tiles.map(t => t.id));
|
||||||
|
for (const rule of rules) {
|
||||||
|
expect(validIds.has(rule.tileId), `${b.id}: invalid tileId ${rule.tileId}`).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Kinetic Mountains Generation', () => {
|
||||||
|
const mtns = allBiomes.find(b => b.id === 'kinetic-mountains')!;
|
||||||
|
|
||||||
|
it('generates correct-size grid', () => {
|
||||||
|
const world = generateWorld(mtns, 42);
|
||||||
|
expect(world.grid).toHaveLength(mtns.mapHeight);
|
||||||
|
expect(world.grid[0]).toHaveLength(mtns.mapWidth);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all tile IDs are valid', () => {
|
||||||
|
const world = generateWorld(mtns, 42);
|
||||||
|
const validIds = new Set(mtns.tiles.map(t => t.id));
|
||||||
|
for (const row of world.grid) {
|
||||||
|
for (const tileId of row) {
|
||||||
|
expect(validIds.has(tileId)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has diverse tiles with no single type > 60%', () => {
|
||||||
|
const world = generateWorld(mtns, 42);
|
||||||
|
const counts = new Map<number, number>();
|
||||||
|
for (const row of world.grid) {
|
||||||
|
for (const t of row) counts.set(t, (counts.get(t) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const total = mtns.mapWidth * mtns.mapHeight;
|
||||||
|
expect(counts.size).toBeGreaterThanOrEqual(4);
|
||||||
|
for (const count of counts.values()) {
|
||||||
|
expect(count / total).toBeLessThan(0.6);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates chasms (low elevation)', () => {
|
||||||
|
const world = generateWorld(mtns, 42);
|
||||||
|
const chasmId = mtns.tiles.find(t => t.name === 'chasm')?.id;
|
||||||
|
expect(world.grid.some(row => row.includes(chasmId!))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates ore deposits (resources)', () => {
|
||||||
|
const world = generateWorld(mtns, 42);
|
||||||
|
const oreId = mtns.tiles.find(t => t.resource)?.id;
|
||||||
|
expect(world.grid.some(row => row.includes(oreId!))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Verdant Forests Generation', () => {
|
||||||
|
const forest = allBiomes.find(b => b.id === 'verdant-forests')!;
|
||||||
|
|
||||||
|
it('generates correct-size grid', () => {
|
||||||
|
const world = generateWorld(forest, 42);
|
||||||
|
expect(world.grid).toHaveLength(forest.mapHeight);
|
||||||
|
expect(world.grid[0]).toHaveLength(forest.mapWidth);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all tile IDs are valid', () => {
|
||||||
|
const world = generateWorld(forest, 42);
|
||||||
|
const validIds = new Set(forest.tiles.map(t => t.id));
|
||||||
|
for (const row of world.grid) {
|
||||||
|
for (const tileId of row) {
|
||||||
|
expect(validIds.has(tileId)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has diverse tiles with no single type > 60%', () => {
|
||||||
|
const world = generateWorld(forest, 42);
|
||||||
|
const counts = new Map<number, number>();
|
||||||
|
for (const row of world.grid) {
|
||||||
|
for (const t of row) counts.set(t, (counts.get(t) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const total = forest.mapWidth * forest.mapHeight;
|
||||||
|
expect(counts.size).toBeGreaterThanOrEqual(4);
|
||||||
|
for (const count of counts.values()) {
|
||||||
|
expect(count / total).toBeLessThan(0.6);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates bogs (low elevation)', () => {
|
||||||
|
const world = generateWorld(forest, 42);
|
||||||
|
const bogId = forest.tiles.find(t => t.name === 'bog')?.id;
|
||||||
|
expect(world.grid.some(row => row.includes(bogId!))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates herb patches (resources)', () => {
|
||||||
|
const world = generateWorld(forest, 42);
|
||||||
|
const herbId = forest.tiles.find(t => t.resource)?.id;
|
||||||
|
expect(world.grid.some(row => row.includes(herbId!))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user