Compare commits
4 Commits
10bd67c951
...
bc472d0f77
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc472d0f77 | ||
|
|
ddbca12740 | ||
|
|
58ebb11747 | ||
|
|
7aabb8b4fc |
@@ -1,27 +1,37 @@
|
|||||||
---
|
---
|
||||||
description: Agent development workflow — visual feedback, testing, progress tracking
|
description: Agent development workflow — TDD, commit discipline, visual feedback
|
||||||
alwaysApply: true
|
alwaysApply: true
|
||||||
---
|
---
|
||||||
|
|
||||||
# Agent Workflow
|
# Agent Workflow
|
||||||
|
|
||||||
|
## Development Order (STRICT)
|
||||||
|
For every task, follow this sequence:
|
||||||
|
1. **Tests first** — write failing tests that define expected behavior
|
||||||
|
2. **Data structures** — define types, interfaces, JSON schemas
|
||||||
|
3. **Implementation** — write code to make tests pass
|
||||||
|
4. **Verify** — `npm run test:run` for logic, Playwright screenshot for visuals
|
||||||
|
5. **Commit** — `git add -A && git commit` after each successful step
|
||||||
|
|
||||||
|
## Commit Discipline
|
||||||
|
- Commit after EVERY successful step (tests green or visual verified)
|
||||||
|
- Small, focused commits — one concern per commit
|
||||||
|
- Never accumulate uncommitted work across multiple tasks
|
||||||
|
- Commit message format: concise "what + why" in English
|
||||||
|
|
||||||
## Visual Verification
|
## Visual Verification
|
||||||
After ANY visual change, verify via Playwright browser tools:
|
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 see result
|
||||||
4. Check browser console for errors
|
4. Check browser console for errors
|
||||||
5. Fix issues before moving on
|
5. Fix issues before committing
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
- **Chemistry, ecology, math** — write vitest tests FIRST, then implement
|
|
||||||
- **Visual/rendering** — verify via Playwright screenshots
|
|
||||||
- Run `npm run test:run` after logic changes
|
|
||||||
|
|
||||||
## After Completing a Task
|
## After Completing a Task
|
||||||
1. Verify it works (test or screenshot)
|
1. Verify it works (test or screenshot)
|
||||||
2. Update `PROGRESS.md` — mark task complete, add date
|
2. Commit immediately
|
||||||
3. Check `IMPLEMENTATION-PLAN.md` for what's next
|
3. Update `PROGRESS.md` — mark task complete
|
||||||
|
4. 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
|
||||||
|
|||||||
1
.playwright-mcp/console-2026-02-12T09-06-28-438Z.log
Normal file
1
.playwright-mcp/console-2026-02-12T09-06-28-438Z.log
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[ 7988ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:5173/favicon.ico:0
|
||||||
54
PROGRESS.md
54
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 0 ✅ → Ready for Phase 1
|
> **Current phase:** Phase 3 ✅ → Ready for Phase 4
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -19,23 +19,52 @@
|
|||||||
- [x] Implementation plan (`IMPLEMENTATION-PLAN.md`)
|
- [x] Implementation plan (`IMPLEMENTATION-PLAN.md`)
|
||||||
- [x] Progress tracking (this file)
|
- [x] Progress tracking (this file)
|
||||||
|
|
||||||
|
### Phase 1: Chemistry Engine ✅
|
||||||
|
- [x] 1.1 Types and interfaces (`src/chemistry/types.ts`)
|
||||||
|
- [x] 1.2 Element data — 20 real elements (`src/data/elements.json`)
|
||||||
|
- [x] 1.3 Element registry with lookup by symbol/number (`src/chemistry/elements.ts`)
|
||||||
|
- [x] 1.4 Reaction engine — O(1) lookup, condition checking, failure reasons (`src/chemistry/engine.ts`)
|
||||||
|
- [x] 1.5 Reaction data — 34 real reactions (`src/data/reactions.json`)
|
||||||
|
- [x] 1.6 Compound data — 25 compounds with game effects (`src/data/compounds.json`)
|
||||||
|
- [x] 1.7 Unit tests — 35 passing (`tests/chemistry.test.ts`)
|
||||||
|
|
||||||
|
### Phase 2: ECS Foundation ✅
|
||||||
|
- [x] 2.1 World setup — bitECS world + time tracking (`src/ecs/world.ts`)
|
||||||
|
- [x] 2.2 Core components — Position, Velocity, SpriteRef, Health, ChemicalComposition (`src/ecs/components.ts`)
|
||||||
|
- [x] 2.3 Movement system — velocity-based + bounce (`src/ecs/systems/movement.ts`)
|
||||||
|
- [x] 2.4 Phaser ↔ bitECS sync bridge — polling-based, creates/destroys/syncs sprites (`src/ecs/bridge.ts`)
|
||||||
|
- [x] 2.5 Entity factory — createGameEntity/removeGameEntity (`src/ecs/factory.ts`)
|
||||||
|
- [x] 2.6 Health/damage system — damage, healing, death detection (`src/ecs/systems/health.ts`)
|
||||||
|
- [x] 2.7 Visual test — 20 colored circles bouncing at 60fps, GameScene (`src/scenes/GameScene.ts`)
|
||||||
|
- [x] Unit tests — 39 passing (`tests/ecs.test.ts`)
|
||||||
|
|
||||||
|
### Phase 3: World Generation ✅
|
||||||
|
- [x] 3.1 Tilemap system — canvas tileset with per-pixel variation + Phaser tilemap (`src/world/tilemap.ts`)
|
||||||
|
- [x] 3.2 Biome data — Catalytic Wastes: 8 tile types (`src/data/biomes.json`)
|
||||||
|
- [x] 3.3 Noise generation — simplex-noise, seeded PRNG (mulberry32), deterministic (`src/world/noise.ts`)
|
||||||
|
- [x] 3.4 World generator — elevation noise → base terrain, detail noise → overlays (`src/world/generator.ts`)
|
||||||
|
- [x] 3.5 Resource placement — geysers on acid-shallow zones, mineral veins on ground (`src/world/generator.ts`)
|
||||||
|
- [x] 3.6 Camera — WASD movement, mouse wheel zoom (0.5x–3x), bounds clamping (`src/world/camera.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`)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## In Progress
|
## In Progress
|
||||||
|
|
||||||
_None — ready to begin Phase 1_
|
_None — ready to begin Phase 4_
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Up Next: Phase 1 — Chemistry Engine
|
## Up Next: Phase 4 — Player Systems
|
||||||
|
|
||||||
- [ ] 1.1 Types and interfaces (`Element`, `Reaction`, `Compound`)
|
- [ ] 4.1 Player entity + WASD controller
|
||||||
- [ ] 1.2 Element data — 20 real elements (JSON)
|
- [ ] 4.2 Inventory (weight-based, element stacking)
|
||||||
- [ ] 1.3 Element registry with lookup
|
- [ ] 4.3 Element collection from world objects
|
||||||
- [ ] 1.4 Reaction engine core
|
- [ ] 4.4 Crafting (chemistry engine integration)
|
||||||
- [ ] 1.5 Reaction data — 50 real reactions (JSON)
|
- [ ] 4.5 Projectile system (throw elements/compounds)
|
||||||
- [ ] 1.6 Compound properties
|
- [ ] 4.6 Quick slots (1-2-3-4 hotkeys)
|
||||||
- [ ] 1.7 Unit tests (vitest)
|
- [ ] 4.7 HUD (UIScene: health ring, inventory bar, element info)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -49,4 +78,7 @@ None
|
|||||||
|
|
||||||
| # | Date | Phase | Summary |
|
| # | Date | Phase | Summary |
|
||||||
|---|------|-------|---------|
|
|---|------|-------|---------|
|
||||||
| 1 | 2026-02-12 | Phase 0 | Project setup complete: GDD, engine analysis, npm init, Phaser config, BootScene, cursor rules, implementation plan |
|
| 1 | 2026-02-12 | Phase 0 | Project setup: GDD, engine analysis, npm init, Phaser config, BootScene, cursor rules, plan |
|
||||||
|
| 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 |
|
||||||
|
| 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) |
|
||||||
|
|||||||
BIN
boot-screen.png
Normal file
BIN
boot-screen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
9
package-lock.json
generated
9
package-lock.json
generated
@@ -9,7 +9,8 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bitecs": "^0.4.0",
|
"bitecs": "^0.4.0",
|
||||||
"phaser": "^3.80.0"
|
"phaser": "^3.80.0",
|
||||||
|
"simplex-noise": "^4.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"happy-dom": "^20.6.1",
|
"happy-dom": "^20.6.1",
|
||||||
@@ -1269,6 +1270,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/simplex-noise": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/simplex-noise/-/simplex-noise-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-qSE2I4AngLQG7BXqoZj51jokT4WUXe8mOBrvfOXpci8+6Yu44+/dD5zqDpOx3Ux792eamTd2lLcI8jqFntk/lg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bitecs": "^0.4.0",
|
"bitecs": "^0.4.0",
|
||||||
"phaser": "^3.80.0"
|
"phaser": "^3.80.0",
|
||||||
|
"simplex-noise": "^4.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"happy-dom": "^20.6.1",
|
"happy-dom": "^20.6.1",
|
||||||
|
|||||||
35
src/chemistry/compounds.ts
Normal file
35
src/chemistry/compounds.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { CompoundData } from './types';
|
||||||
|
import compoundsRaw from '../data/compounds.json';
|
||||||
|
|
||||||
|
const compounds: CompoundData[] = compoundsRaw as CompoundData[];
|
||||||
|
|
||||||
|
const byId = new Map<string, CompoundData>();
|
||||||
|
|
||||||
|
for (const c of compounds) {
|
||||||
|
if (byId.has(c.id)) {
|
||||||
|
throw new Error(`Duplicate compound id: ${c.id}`);
|
||||||
|
}
|
||||||
|
byId.set(c.id, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CompoundRegistry = {
|
||||||
|
getById(id: string): CompoundData | undefined {
|
||||||
|
return byId.get(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
getAll(): readonly CompoundData[] {
|
||||||
|
return compounds;
|
||||||
|
},
|
||||||
|
|
||||||
|
has(id: string): boolean {
|
||||||
|
return byId.has(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
count(): number {
|
||||||
|
return compounds.length;
|
||||||
|
},
|
||||||
|
|
||||||
|
isCompound(id: string): boolean {
|
||||||
|
return byId.has(id);
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
45
src/chemistry/elements.ts
Normal file
45
src/chemistry/elements.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import type { ElementData } from './types';
|
||||||
|
import elementsRaw from '../data/elements.json';
|
||||||
|
|
||||||
|
const elements: ElementData[] = elementsRaw as ElementData[];
|
||||||
|
|
||||||
|
const bySymbol = new Map<string, ElementData>();
|
||||||
|
const byNumber = new Map<number, ElementData>();
|
||||||
|
|
||||||
|
for (const el of elements) {
|
||||||
|
if (bySymbol.has(el.symbol)) {
|
||||||
|
throw new Error(`Duplicate element symbol: ${el.symbol}`);
|
||||||
|
}
|
||||||
|
if (byNumber.has(el.atomicNumber)) {
|
||||||
|
throw new Error(`Duplicate atomic number: ${el.atomicNumber}`);
|
||||||
|
}
|
||||||
|
bySymbol.set(el.symbol, el);
|
||||||
|
byNumber.set(el.atomicNumber, el);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ElementRegistry = {
|
||||||
|
getBySymbol(symbol: string): ElementData | undefined {
|
||||||
|
return bySymbol.get(symbol);
|
||||||
|
},
|
||||||
|
|
||||||
|
getByNumber(num: number): ElementData | undefined {
|
||||||
|
return byNumber.get(num);
|
||||||
|
},
|
||||||
|
|
||||||
|
getAll(): readonly ElementData[] {
|
||||||
|
return elements;
|
||||||
|
},
|
||||||
|
|
||||||
|
has(symbol: string): boolean {
|
||||||
|
return bySymbol.has(symbol);
|
||||||
|
},
|
||||||
|
|
||||||
|
count(): number {
|
||||||
|
return elements.length;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Check if a symbol is an element (vs compound) */
|
||||||
|
isElement(id: string): boolean {
|
||||||
|
return bySymbol.has(id);
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
209
src/chemistry/engine.ts
Normal file
209
src/chemistry/engine.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import type { Reactant, ReactionData, ReactionResult, ReactionConditions } from './types';
|
||||||
|
import { ElementRegistry } from './elements';
|
||||||
|
import { CompoundRegistry } from './compounds';
|
||||||
|
import reactionsRaw from '../data/reactions.json';
|
||||||
|
|
||||||
|
const reactions: ReactionData[] = reactionsRaw as ReactionData[];
|
||||||
|
|
||||||
|
// === Reaction Key: sorted "id:count" pairs joined by "+" ===
|
||||||
|
|
||||||
|
function makeReactionKey(reactants: Reactant[]): string {
|
||||||
|
return reactants
|
||||||
|
.map((r) => `${r.id}:${r.count}`)
|
||||||
|
.sort()
|
||||||
|
.join('+');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build index for O(1) lookup
|
||||||
|
const reactionIndex = new Map<string, ReactionData>();
|
||||||
|
|
||||||
|
for (const r of reactions) {
|
||||||
|
const key = makeReactionKey(r.reactants);
|
||||||
|
if (reactionIndex.has(key)) {
|
||||||
|
console.warn(`Duplicate reaction key: ${key} (${r.id} conflicts with ${reactionIndex.get(key)!.id})`);
|
||||||
|
}
|
||||||
|
reactionIndex.set(key, r);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Failure Reason Generation ===
|
||||||
|
|
||||||
|
function isNobleGas(id: string): boolean {
|
||||||
|
const el = ElementRegistry.getBySymbol(id);
|
||||||
|
return el?.category === 'noble-gas';
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateFailureReason(
|
||||||
|
reactants: Reactant[],
|
||||||
|
conditions?: Partial<ReactionConditions>,
|
||||||
|
): { reason: string; reasonRu: string } {
|
||||||
|
const ids = reactants.map((r) => r.id);
|
||||||
|
|
||||||
|
// Check for noble gas
|
||||||
|
const nobleGas = ids.find((id) => isNobleGas(id));
|
||||||
|
if (nobleGas) {
|
||||||
|
const el = ElementRegistry.getBySymbol(nobleGas)!;
|
||||||
|
return {
|
||||||
|
reason: `${el.name} is a noble gas — it has a full electron shell and refuses to react with anything.`,
|
||||||
|
reasonRu: `${el.nameRu} — благородный газ с полной электронной оболочкой. Не реагирует ни с чем.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all inputs are the same element
|
||||||
|
const uniqueIds = new Set(ids);
|
||||||
|
if (uniqueIds.size === 1) {
|
||||||
|
return {
|
||||||
|
reason: `Cannot react an element with itself under these conditions. Try combining with a different element.`,
|
||||||
|
reasonRu: `Нельзя провести реакцию элемента с самим собой в этих условиях. Попробуйте другой элемент.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for gold (extremely unreactive)
|
||||||
|
if (ids.includes('Au')) {
|
||||||
|
return {
|
||||||
|
reason: `Gold is extremely unreactive — it resists most chemical attacks. Only aqua regia (HNO₃ + HCl) can dissolve it.`,
|
||||||
|
reasonRu: `Золото крайне инертно — устойчиво к большинству реагентов. Только царская водка (HNO₃ + HCl) растворяет его.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a matching reaction exists but conditions aren't met
|
||||||
|
const key = makeReactionKey(reactants);
|
||||||
|
// Try nearby keys (different counts)
|
||||||
|
for (const [rKey, reaction] of reactionIndex) {
|
||||||
|
const rIds = new Set(reaction.reactants.map((r) => r.id));
|
||||||
|
const inputIds = new Set(ids);
|
||||||
|
if (setsEqual(rIds, inputIds) && rKey !== key) {
|
||||||
|
return {
|
||||||
|
reason: `These elements can react, but you need different proportions. Check the amounts carefully.`,
|
||||||
|
reasonRu: `Эти элементы могут реагировать, но нужны другие пропорции. Проверьте количества.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default
|
||||||
|
return {
|
||||||
|
reason: `No known reaction between these substances under current conditions. Try adding heat, a catalyst, or different elements.`,
|
||||||
|
reasonRu: `Нет известной реакции между этими веществами в текущих условиях. Попробуйте нагрев, катализатор или другие элементы.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setsEqual<T>(a: Set<T>, b: Set<T>): boolean {
|
||||||
|
if (a.size !== b.size) return false;
|
||||||
|
for (const item of a) {
|
||||||
|
if (!b.has(item)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Condition Checking ===
|
||||||
|
|
||||||
|
function checkConditions(
|
||||||
|
required: ReactionConditions | undefined,
|
||||||
|
provided: Partial<ReactionConditions> | undefined,
|
||||||
|
): { met: boolean; reason?: string; reasonRu?: string } {
|
||||||
|
if (!required) return { met: true };
|
||||||
|
|
||||||
|
if (required.minTemp && (!provided?.minTemp || provided.minTemp < required.minTemp)) {
|
||||||
|
return {
|
||||||
|
met: false,
|
||||||
|
reason: `This reaction requires a temperature of at least ${required.minTemp}°. You need a heat source.`,
|
||||||
|
reasonRu: `Эта реакция требует температуру не менее ${required.minTemp}°. Нужен источник тепла.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (required.catalyst) {
|
||||||
|
const catalystName =
|
||||||
|
ElementRegistry.getBySymbol(required.catalyst)?.name ??
|
||||||
|
CompoundRegistry.getById(required.catalyst)?.name ??
|
||||||
|
required.catalyst;
|
||||||
|
|
||||||
|
if (!provided?.catalyst || provided.catalyst !== required.catalyst) {
|
||||||
|
return {
|
||||||
|
met: false,
|
||||||
|
reason: `This reaction requires a catalyst: ${catalystName}. The catalyst is not consumed — it just enables the reaction.`,
|
||||||
|
reasonRu: `Эта реакция требует катализатор: ${catalystName}. Катализатор не расходуется — он лишь запускает реакцию.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (required.requiresEnergy && !provided?.requiresEnergy) {
|
||||||
|
return {
|
||||||
|
met: false,
|
||||||
|
reason: `This reaction requires an external energy source (e.g., electricity). It's endothermic — it absorbs energy.`,
|
||||||
|
reasonRu: `Эта реакция требует внешний источник энергии (напр., электричество). Она эндотермическая — поглощает энергию.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { met: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Public API ===
|
||||||
|
|
||||||
|
export const ReactionEngine = {
|
||||||
|
/**
|
||||||
|
* Attempt a reaction with given reactants and conditions.
|
||||||
|
* Returns success with products, or failure with educational reason.
|
||||||
|
*/
|
||||||
|
react(
|
||||||
|
reactants: Reactant[],
|
||||||
|
conditions?: Partial<ReactionConditions>,
|
||||||
|
): ReactionResult {
|
||||||
|
// Validate all inputs exist
|
||||||
|
for (const r of reactants) {
|
||||||
|
if (!ElementRegistry.isElement(r.id) && !CompoundRegistry.isCompound(r.id)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
failureReason: `Unknown substance: "${r.id}". Check spelling.`,
|
||||||
|
failureReasonRu: `Неизвестное вещество: "${r.id}". Проверьте написание.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = makeReactionKey(reactants);
|
||||||
|
const reaction = reactionIndex.get(key);
|
||||||
|
|
||||||
|
if (!reaction) {
|
||||||
|
const { reason, reasonRu } = generateFailureReason(reactants, conditions);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
failureReason: reason,
|
||||||
|
failureReasonRu: reasonRu,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check conditions
|
||||||
|
const condCheck = checkConditions(reaction.conditions, conditions);
|
||||||
|
if (!condCheck.met) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
failureReason: condCheck.reason,
|
||||||
|
failureReasonRu: condCheck.reasonRu,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
products: reaction.products,
|
||||||
|
reaction,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Get a reaction by its ID */
|
||||||
|
getById(id: string): ReactionData | undefined {
|
||||||
|
return reactions.find((r) => r.id === id);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Get all known reactions */
|
||||||
|
getAll(): readonly ReactionData[] {
|
||||||
|
return reactions;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Get the number of registered reactions */
|
||||||
|
count(): number {
|
||||||
|
return reactions.length;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Build a reaction key from reactants (for testing/debugging) */
|
||||||
|
makeKey(reactants: Reactant[]): string {
|
||||||
|
return makeReactionKey(reactants);
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
102
src/chemistry/types.ts
Normal file
102
src/chemistry/types.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
// === Element Types ===
|
||||||
|
|
||||||
|
export type ElementCategory =
|
||||||
|
| 'alkali-metal'
|
||||||
|
| 'alkaline-earth'
|
||||||
|
| 'transition-metal'
|
||||||
|
| 'post-transition-metal'
|
||||||
|
| 'metalloid'
|
||||||
|
| 'nonmetal'
|
||||||
|
| 'halogen'
|
||||||
|
| 'noble-gas';
|
||||||
|
|
||||||
|
export type MatterState = 'solid' | 'liquid' | 'gas';
|
||||||
|
|
||||||
|
export interface ElementData {
|
||||||
|
symbol: string;
|
||||||
|
name: string;
|
||||||
|
nameRu: string;
|
||||||
|
atomicNumber: number;
|
||||||
|
atomicMass: number;
|
||||||
|
electronegativity: number; // 0 for noble gases
|
||||||
|
category: ElementCategory;
|
||||||
|
state: MatterState; // at room temperature
|
||||||
|
color: string; // hex for rendering
|
||||||
|
description: string;
|
||||||
|
descriptionRu: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Compound Types ===
|
||||||
|
|
||||||
|
export interface CompoundData {
|
||||||
|
id: string; // lookup key, e.g. "NaCl"
|
||||||
|
formula: string; // display formula with Unicode, e.g. "NaCl"
|
||||||
|
name: string;
|
||||||
|
nameRu: string;
|
||||||
|
mass: number; // molecular mass
|
||||||
|
state: MatterState;
|
||||||
|
color: string;
|
||||||
|
properties: CompoundProperties;
|
||||||
|
description: string;
|
||||||
|
descriptionRu: string;
|
||||||
|
gameEffects: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompoundProperties {
|
||||||
|
flammable: boolean;
|
||||||
|
toxic: boolean;
|
||||||
|
explosive: boolean;
|
||||||
|
acidic: boolean;
|
||||||
|
basic: boolean;
|
||||||
|
oxidizer: boolean;
|
||||||
|
corrosive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Reaction Types ===
|
||||||
|
|
||||||
|
export type ReactionType =
|
||||||
|
| 'synthesis'
|
||||||
|
| 'decomposition'
|
||||||
|
| 'combustion'
|
||||||
|
| 'single-replacement'
|
||||||
|
| 'double-replacement'
|
||||||
|
| 'acid-base'
|
||||||
|
| 'redox';
|
||||||
|
|
||||||
|
export interface Reactant {
|
||||||
|
id: string; // element symbol or compound id
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Product {
|
||||||
|
id: string; // element symbol or compound id
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReactionConditions {
|
||||||
|
minTemp?: number; // 0=room, 100=boiling, 500=fire, 1000=furnace
|
||||||
|
catalyst?: string; // compound id required as catalyst
|
||||||
|
requiresEnergy?: boolean; // needs external energy (electricity)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReactionData {
|
||||||
|
id: string;
|
||||||
|
reactants: Reactant[];
|
||||||
|
products: Product[];
|
||||||
|
conditions?: ReactionConditions;
|
||||||
|
energyChange: number; // -100..+100, negative = exothermic
|
||||||
|
type: ReactionType;
|
||||||
|
description: string;
|
||||||
|
descriptionRu: string;
|
||||||
|
difficulty: number; // 1-5
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Engine Result ===
|
||||||
|
|
||||||
|
export interface ReactionResult {
|
||||||
|
success: boolean;
|
||||||
|
products?: Product[];
|
||||||
|
reaction?: ReactionData;
|
||||||
|
failureReason?: string;
|
||||||
|
failureReasonRu?: string;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
import { BootScene } from './scenes/BootScene';
|
import { BootScene } from './scenes/BootScene';
|
||||||
|
import { GameScene } from './scenes/GameScene';
|
||||||
|
|
||||||
export const GAME_WIDTH = 1280;
|
export const GAME_WIDTH = 1280;
|
||||||
export const GAME_HEIGHT = 720;
|
export const GAME_HEIGHT = 720;
|
||||||
@@ -10,7 +11,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],
|
scene: [BootScene, GameScene],
|
||||||
physics: {
|
physics: {
|
||||||
default: 'arcade',
|
default: 'arcade',
|
||||||
arcade: {
|
arcade: {
|
||||||
|
|||||||
38
src/data/biomes.json
Normal file
38
src/data/biomes.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "catalytic-wastes",
|
||||||
|
"name": "Catalytic Wastes",
|
||||||
|
"nameRu": "Каталитические Пустоши",
|
||||||
|
"description": "A blasted landscape of scorched earth, acid pools, and crystalline formations",
|
||||||
|
"descriptionRu": "Опалённый ландшафт из выжженной земли, кислотных озёр и кристаллических формаций",
|
||||||
|
"tileSize": 32,
|
||||||
|
"mapWidth": 80,
|
||||||
|
"mapHeight": 80,
|
||||||
|
"tiles": [
|
||||||
|
{ "id": 0, "name": "scorched-earth", "nameRu": "Выжженная земля", "color": "#2a1f0e", "walkable": true, "damage": 0, "interactive": false, "resource": false },
|
||||||
|
{ "id": 1, "name": "cracked-ground", "nameRu": "Потрескавшаяся земля", "color": "#3d2b14", "walkable": true, "damage": 0, "interactive": false, "resource": false },
|
||||||
|
{ "id": 2, "name": "ash-sand", "nameRu": "Пепельный песок", "color": "#4a3d2e", "walkable": true, "damage": 0, "interactive": false, "resource": false },
|
||||||
|
{ "id": 3, "name": "acid-pool", "nameRu": "Кислотное озеро", "color": "#1a6b0a", "walkable": false, "damage": 10, "interactive": false, "resource": false },
|
||||||
|
{ "id": 4, "name": "acid-shallow", "nameRu": "Кислотная отмель", "color": "#3a9420", "walkable": true, "damage": 3, "interactive": false, "resource": false },
|
||||||
|
{ "id": 5, "name": "crystal", "nameRu": "Кристаллическая формация", "color": "#7b5ea7", "walkable": false, "damage": 0, "interactive": false, "resource": false },
|
||||||
|
{ "id": 6, "name": "geyser", "nameRu": "Гейзер", "color": "#e85d10", "walkable": false, "damage": 0, "interactive": true, "resource": false },
|
||||||
|
{ "id": 7, "name": "mineral-vein", "nameRu": "Минеральная жила", "color": "#c0a030", "walkable": true, "damage": 0, "interactive": false, "resource": true }
|
||||||
|
],
|
||||||
|
"generation": {
|
||||||
|
"elevationScale": 0.06,
|
||||||
|
"detailScale": 0.15,
|
||||||
|
"elevationRules": [
|
||||||
|
{ "below": 0.22, "tileId": 3 },
|
||||||
|
{ "below": 0.30, "tileId": 4 },
|
||||||
|
{ "below": 0.52, "tileId": 0 },
|
||||||
|
{ "below": 0.70, "tileId": 1 },
|
||||||
|
{ "below": 0.84, "tileId": 2 },
|
||||||
|
{ "below": 1.00, "tileId": 5 }
|
||||||
|
],
|
||||||
|
"geyserThreshold": 0.93,
|
||||||
|
"mineralThreshold": 0.90,
|
||||||
|
"geyserOnTile": 4,
|
||||||
|
"mineralOnTiles": [0, 1, 2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
202
src/data/compounds.json
Normal file
202
src/data/compounds.json
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "H2O", "formula": "H₂O", "name": "Water", "nameRu": "Вода",
|
||||||
|
"mass": 18.015, "state": "liquid", "color": "#4488cc",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Universal solvent. Essential for life and countless reactions.",
|
||||||
|
"descriptionRu": "Универсальный растворитель. Необходима для жизни и бесчисленных реакций.",
|
||||||
|
"gameEffects": ["hydration", "cooling", "solvent"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "NaCl", "formula": "NaCl", "name": "Sodium Chloride", "nameRu": "Поваренная соль",
|
||||||
|
"mass": 58.44, "state": "solid", "color": "#f0f0f0",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Table salt. Preserves food via osmosis. Essential electrolyte.",
|
||||||
|
"descriptionRu": "Поваренная соль. Консервирует пищу через осмос. Необходимый электролит.",
|
||||||
|
"gameEffects": ["preservation", "trade", "slug_repellent"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "CO2", "formula": "CO₂", "name": "Carbon Dioxide", "nameRu": "Углекислый газ",
|
||||||
|
"mass": 44.01, "state": "gas", "color": "#aaaaaa",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": true, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Product of combustion and respiration. Denser than air — extinguishes fire.",
|
||||||
|
"descriptionRu": "Продукт горения и дыхания. Тяжелее воздуха — тушит огонь.",
|
||||||
|
"gameEffects": ["fire_extinguisher", "suffocant"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "CO", "formula": "CO", "name": "Carbon Monoxide", "nameRu": "Угарный газ",
|
||||||
|
"mass": 28.01, "state": "gas", "color": "#cccccc",
|
||||||
|
"properties": { "flammable": true, "toxic": true, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Odorless, deadly poison. Binds to hemoglobin 200x stronger than oxygen.",
|
||||||
|
"descriptionRu": "Без запаха, смертельно ядовит. Связывается с гемоглобином в 200 раз сильнее кислорода.",
|
||||||
|
"gameEffects": ["poison_gas", "fuel"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "HCl", "formula": "HCl", "name": "Hydrochloric Acid", "nameRu": "Соляная кислота",
|
||||||
|
"mass": 36.46, "state": "liquid", "color": "#ccff00",
|
||||||
|
"properties": { "flammable": false, "toxic": true, "explosive": false, "acidic": true, "basic": false, "oxidizer": false, "corrosive": true },
|
||||||
|
"description": "Strong acid. Dissolves metals and limestone. Present in stomach acid.",
|
||||||
|
"descriptionRu": "Сильная кислота. Растворяет металлы и известняк. Содержится в желудочном соке.",
|
||||||
|
"gameEffects": ["dissolve_metal", "dissolve_stone", "damage"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "NaOH", "formula": "NaOH", "name": "Sodium Hydroxide", "nameRu": "Гидроксид натрия",
|
||||||
|
"mass": 40.00, "state": "solid", "color": "#f5f5f5",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": true, "oxidizer": false, "corrosive": true },
|
||||||
|
"description": "Lye. Extremely caustic. Used for soap making and as a powerful cleaning agent.",
|
||||||
|
"descriptionRu": "Щёлочь. Крайне едкий. Используется для мыловарения и как мощное чистящее средство.",
|
||||||
|
"gameEffects": ["soap_making", "corrosive_weapon", "cleaning"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KOH", "formula": "KOH", "name": "Potassium Hydroxide", "nameRu": "Гидроксид калия",
|
||||||
|
"mass": 56.11, "state": "solid", "color": "#f0f0f0",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": true, "oxidizer": false, "corrosive": true },
|
||||||
|
"description": "Caustic potash. Even stronger base than NaOH. Key in fertilizer production.",
|
||||||
|
"descriptionRu": "Едкое кали. Ещё более сильное основание, чем NaOH. Ключ к производству удобрений.",
|
||||||
|
"gameEffects": ["corrosive_weapon", "fertilizer_base"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "CaO", "formula": "CaO", "name": "Calcium Oxide", "nameRu": "Негашёная известь",
|
||||||
|
"mass": 56.08, "state": "solid", "color": "#f5f5dc",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": true, "oxidizer": false, "corrosive": true },
|
||||||
|
"description": "Quickite. Reacts violently with water, generating intense heat. Building material.",
|
||||||
|
"descriptionRu": "Негашёная известь. Бурно реагирует с водой, выделяя сильное тепло. Строительный материал.",
|
||||||
|
"gameEffects": ["heat_source", "building", "disinfectant"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "CaOH2", "formula": "Ca(OH)₂", "name": "Calcium Hydroxide", "nameRu": "Гашёная известь",
|
||||||
|
"mass": 74.09, "state": "solid", "color": "#f5f5f0",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": true, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Slaked lime. Used in mortar, plaster, and water purification.",
|
||||||
|
"descriptionRu": "Гашёная известь. Используется в строительном растворе, штукатурке и очистке воды.",
|
||||||
|
"gameEffects": ["building_mortar", "water_purification", "disinfectant"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "MgO", "formula": "MgO", "name": "Magnesium Oxide", "nameRu": "Оксид магния",
|
||||||
|
"mass": 40.30, "state": "solid", "color": "#ffffff",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": true, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Produced by burning magnesium with a blinding white flash. Refractory material.",
|
||||||
|
"descriptionRu": "Образуется при горении магния с ослепительной белой вспышкой. Огнеупорный материал.",
|
||||||
|
"gameEffects": ["flash_blind", "refractory"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Fe2O3", "formula": "Fe₂O₃", "name": "Iron(III) Oxide", "nameRu": "Оксид железа (ржавчина)",
|
||||||
|
"mass": 159.69, "state": "solid", "color": "#993300",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Rust. Indicator of aging metal. Key ingredient for thermite (with aluminum).",
|
||||||
|
"descriptionRu": "Ржавчина. Индикатор старения металла. Ключевой ингредиент термита (с алюминием).",
|
||||||
|
"gameEffects": ["thermite_ingredient", "pigment"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "SiO2", "formula": "SiO₂", "name": "Silicon Dioxide", "nameRu": "Диоксид кремния",
|
||||||
|
"mass": 60.08, "state": "solid", "color": "#e8e8e8",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Silica. Main component of sand, quartz, and glass. Extremely hard.",
|
||||||
|
"descriptionRu": "Кремнезём. Основной компонент песка, кварца и стекла. Крайне твёрдый.",
|
||||||
|
"gameEffects": ["glass_making", "abrasive", "building"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "FeS", "formula": "FeS", "name": "Iron Sulfide", "nameRu": "Сульфид железа",
|
||||||
|
"mass": 87.91, "state": "solid", "color": "#b8a000",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Fool's gold (pyrite). Sparks when struck — natural fire starter.",
|
||||||
|
"descriptionRu": "Пирит (золото дураков). Искрит при ударе — природное огниво.",
|
||||||
|
"gameEffects": ["fire_starter", "trade_decoy"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ZnS", "formula": "ZnS", "name": "Zinc Sulfide", "nameRu": "Сульфид цинка",
|
||||||
|
"mass": 97.47, "state": "solid", "color": "#eeffdd",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Phosphorescent — glows in the dark after exposure to light. Natural night-light.",
|
||||||
|
"descriptionRu": "Фосфоресцирует — светится в темноте после облучения светом. Природный ночник.",
|
||||||
|
"gameEffects": ["glow", "cave_light", "marking"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "CuS", "formula": "CuS", "name": "Copper Sulfide", "nameRu": "Сульфид меди",
|
||||||
|
"mass": 95.61, "state": "solid", "color": "#1a1a2e",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Dark mineral. Copper ore found near volcanic activity.",
|
||||||
|
"descriptionRu": "Тёмный минерал. Медная руда, встречающаяся вблизи вулканической активности.",
|
||||||
|
"gameEffects": ["pigment", "copper_source"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KCl", "formula": "KCl", "name": "Potassium Chloride", "nameRu": "Хлорид калия",
|
||||||
|
"mass": 74.55, "state": "solid", "color": "#f0f0f0",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Potash salt. Essential plant fertilizer. Salt substitute for food.",
|
||||||
|
"descriptionRu": "Калийная соль. Необходимое удобрение для растений. Заменитель соли в пище.",
|
||||||
|
"gameEffects": ["fertilizer", "preservation"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "SO2", "formula": "SO₂", "name": "Sulfur Dioxide", "nameRu": "Диоксид серы",
|
||||||
|
"mass": 64.07, "state": "gas", "color": "#cccc00",
|
||||||
|
"properties": { "flammable": false, "toxic": true, "explosive": false, "acidic": true, "basic": false, "oxidizer": false, "corrosive": true },
|
||||||
|
"description": "Pungent, suffocating gas. Fumigant and preservative. Causes acid rain.",
|
||||||
|
"descriptionRu": "Едкий, удушливый газ. Фумигант и консервант. Вызывает кислотные дожди.",
|
||||||
|
"gameEffects": ["fumigant", "area_denial", "preservation"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "CaCO3", "formula": "CaCO₃", "name": "Calcium Carbonate", "nameRu": "Карбонат кальция",
|
||||||
|
"mass": 100.09, "state": "solid", "color": "#f5f0e0",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": true, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Limestone, chalk, marble. Building material. Neutralizes acid soil and acid attacks.",
|
||||||
|
"descriptionRu": "Известняк, мел, мрамор. Строительный материал. Нейтрализует кислую почву и кислотные атаки.",
|
||||||
|
"gameEffects": ["building", "acid_neutralizer", "soil_amendment"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "NaHCO3", "formula": "NaHCO₃", "name": "Sodium Bicarbonate", "nameRu": "Пищевая сода",
|
||||||
|
"mass": 84.01, "state": "solid", "color": "#ffffff",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": true, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Baking soda. Neutralizes acids, extinguishes grease fires, gentle cleaning agent.",
|
||||||
|
"descriptionRu": "Пищевая сода. Нейтрализует кислоты, тушит жировые пожары, мягкое чистящее средство.",
|
||||||
|
"gameEffects": ["acid_neutralizer", "fire_extinguisher", "baking"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KNO3", "formula": "KNO₃", "name": "Potassium Nitrate", "nameRu": "Калиевая селитра",
|
||||||
|
"mass": 101.10, "state": "solid", "color": "#f0f0f0",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": true, "corrosive": false },
|
||||||
|
"description": "Saltpeter. Powerful oxidizer. Key component of black gunpowder (with sulfur and charcoal).",
|
||||||
|
"descriptionRu": "Селитра. Мощный окислитель. Ключевой компонент чёрного пороха (с серой и углём).",
|
||||||
|
"gameEffects": ["gunpowder_ingredient", "oxidizer", "fertilizer"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ZnO", "formula": "ZnO", "name": "Zinc Oxide", "nameRu": "Оксид цинка",
|
||||||
|
"mass": 81.38, "state": "solid", "color": "#ffffff",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "White powder. UV protection, wound healing, anti-inflammatory. Used in sunscreen and ointments.",
|
||||||
|
"descriptionRu": "Белый порошок. Защита от УФ, заживление ран, противовоспалительное. Основа мазей.",
|
||||||
|
"gameEffects": ["healing_salve", "sun_protection"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Al2O3", "formula": "Al₂O₃", "name": "Aluminum Oxide", "nameRu": "Оксид алюминия",
|
||||||
|
"mass": 101.96, "state": "solid", "color": "#f0f0f0",
|
||||||
|
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Corundum. Extremely hard (9 on Mohs scale). Rubies and sapphires are colored alumina.",
|
||||||
|
"descriptionRu": "Корунд. Крайне твёрдый (9 по шкале Мооса). Рубины и сапфиры — окрашенный корунд.",
|
||||||
|
"gameEffects": ["abrasive", "armor_material", "gemstone"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "CH4", "formula": "CH₄", "name": "Methane", "nameRu": "Метан",
|
||||||
|
"mass": 16.04, "state": "gas", "color": "#aaddff",
|
||||||
|
"properties": { "flammable": true, "toxic": false, "explosive": true, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Simplest hydrocarbon. Odorless, highly flammable. Main component of natural gas.",
|
||||||
|
"descriptionRu": "Простейший углеводород. Без запаха, легковоспламеняем. Основной компонент природного газа.",
|
||||||
|
"gameEffects": ["fuel", "explosive_gas", "lantern_fuel"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "C2H5OH", "formula": "C₂H₅OH", "name": "Ethanol", "nameRu": "Этанол",
|
||||||
|
"mass": 46.07, "state": "liquid", "color": "#eeeeff",
|
||||||
|
"properties": { "flammable": true, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Drinking alcohol. Disinfectant, fuel, solvent. Intoxicates NPCs.",
|
||||||
|
"descriptionRu": "Питьевой спирт. Дезинфектант, топливо, растворитель. Опьяняет NPC.",
|
||||||
|
"gameEffects": ["disinfectant", "fuel", "intoxicant", "solvent"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "GUNPOWDER", "formula": "KNO₃+S+C", "name": "Black Gunpowder", "nameRu": "Чёрный порох",
|
||||||
|
"mass": 165.17, "state": "solid", "color": "#333333",
|
||||||
|
"properties": { "flammable": true, "toxic": false, "explosive": true, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
|
||||||
|
"description": "Explosive mixture of saltpeter, sulfur, and charcoal. The discovery that changed civilizations.",
|
||||||
|
"descriptionRu": "Взрывчатая смесь селитры, серы и угля. Открытие, изменившее цивилизации.",
|
||||||
|
"gameEffects": ["explosive", "propellant", "signal_flare"]
|
||||||
|
}
|
||||||
|
]
|
||||||
262
src/data/elements.json
Normal file
262
src/data/elements.json
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"symbol": "H",
|
||||||
|
"name": "Hydrogen",
|
||||||
|
"nameRu": "Водород",
|
||||||
|
"atomicNumber": 1,
|
||||||
|
"atomicMass": 1.008,
|
||||||
|
"electronegativity": 2.20,
|
||||||
|
"category": "nonmetal",
|
||||||
|
"state": "gas",
|
||||||
|
"color": "#ffffff",
|
||||||
|
"description": "Lightest element. Highly flammable gas. The most abundant element in the universe.",
|
||||||
|
"descriptionRu": "Легчайший элемент. Легковоспламеняемый газ. Самый распространённый элемент во вселенной."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "He",
|
||||||
|
"name": "Helium",
|
||||||
|
"nameRu": "Гелий",
|
||||||
|
"atomicNumber": 2,
|
||||||
|
"atomicMass": 4.003,
|
||||||
|
"electronegativity": 0,
|
||||||
|
"category": "noble-gas",
|
||||||
|
"state": "gas",
|
||||||
|
"color": "#d9ffff",
|
||||||
|
"description": "Noble gas. Completely inert — refuses to react with anything. Lighter than air.",
|
||||||
|
"descriptionRu": "Благородный газ. Абсолютно инертен — не реагирует ни с чем. Легче воздуха."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "C",
|
||||||
|
"name": "Carbon",
|
||||||
|
"nameRu": "Углерод",
|
||||||
|
"atomicNumber": 6,
|
||||||
|
"atomicMass": 12.011,
|
||||||
|
"electronegativity": 2.55,
|
||||||
|
"category": "nonmetal",
|
||||||
|
"state": "solid",
|
||||||
|
"color": "#333333",
|
||||||
|
"description": "Basis of all organic chemistry. Forms more compounds than any other element. Burns in oxygen.",
|
||||||
|
"descriptionRu": "Основа всей органической химии. Образует больше соединений, чем любой другой элемент. Горит в кислороде."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "N",
|
||||||
|
"name": "Nitrogen",
|
||||||
|
"nameRu": "Азот",
|
||||||
|
"atomicNumber": 7,
|
||||||
|
"atomicMass": 14.007,
|
||||||
|
"electronegativity": 3.04,
|
||||||
|
"category": "nonmetal",
|
||||||
|
"state": "gas",
|
||||||
|
"color": "#3050f8",
|
||||||
|
"description": "Makes up 78% of air. Very stable — triple bond is hard to break. Essential for life (proteins, DNA).",
|
||||||
|
"descriptionRu": "Составляет 78% воздуха. Очень стабилен — тройную связь трудно разорвать. Необходим для жизни (белки, ДНК)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "O",
|
||||||
|
"name": "Oxygen",
|
||||||
|
"nameRu": "Кислород",
|
||||||
|
"atomicNumber": 8,
|
||||||
|
"atomicMass": 15.999,
|
||||||
|
"electronegativity": 3.44,
|
||||||
|
"category": "nonmetal",
|
||||||
|
"state": "gas",
|
||||||
|
"color": "#ff0d0d",
|
||||||
|
"description": "Essential for combustion and respiration. Highly reactive oxidizer. 21% of air.",
|
||||||
|
"descriptionRu": "Необходим для горения и дыхания. Сильный окислитель. 21% воздуха."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "Na",
|
||||||
|
"name": "Sodium",
|
||||||
|
"nameRu": "Натрий",
|
||||||
|
"atomicNumber": 11,
|
||||||
|
"atomicMass": 22.990,
|
||||||
|
"electronegativity": 0.93,
|
||||||
|
"category": "alkali-metal",
|
||||||
|
"state": "solid",
|
||||||
|
"color": "#c8c8c8",
|
||||||
|
"description": "Soft alkali metal. Reacts violently with water — explosion and flame! Store away from moisture.",
|
||||||
|
"descriptionRu": "Мягкий щелочной металл. Бурно реагирует с водой — взрыв и пламя! Хранить вдали от влаги."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "Mg",
|
||||||
|
"name": "Magnesium",
|
||||||
|
"nameRu": "Магний",
|
||||||
|
"atomicNumber": 12,
|
||||||
|
"atomicMass": 24.305,
|
||||||
|
"electronegativity": 1.31,
|
||||||
|
"category": "alkaline-earth",
|
||||||
|
"state": "solid",
|
||||||
|
"color": "#8aff00",
|
||||||
|
"description": "Burns with an intensely bright white flame. Used in flares and incendiary devices.",
|
||||||
|
"descriptionRu": "Горит ослепительно ярким белым пламенем. Используется в сигнальных ракетах и зажигательных устройствах."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "Al",
|
||||||
|
"name": "Aluminum",
|
||||||
|
"nameRu": "Алюминий",
|
||||||
|
"atomicNumber": 13,
|
||||||
|
"atomicMass": 26.982,
|
||||||
|
"electronegativity": 1.61,
|
||||||
|
"category": "post-transition-metal",
|
||||||
|
"state": "solid",
|
||||||
|
"color": "#bfa6a6",
|
||||||
|
"description": "Light, strong metal. Oxide layer protects from corrosion. Key ingredient in thermite.",
|
||||||
|
"descriptionRu": "Лёгкий, прочный металл. Оксидная плёнка защищает от коррозии. Ключевой ингредиент термита."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "Si",
|
||||||
|
"name": "Silicon",
|
||||||
|
"nameRu": "Кремний",
|
||||||
|
"atomicNumber": 14,
|
||||||
|
"atomicMass": 28.086,
|
||||||
|
"electronegativity": 1.90,
|
||||||
|
"category": "metalloid",
|
||||||
|
"state": "solid",
|
||||||
|
"color": "#f0c8a0",
|
||||||
|
"description": "Semiconductor. Basis of glass, ceramics, and electronics. Second most abundant in Earth's crust.",
|
||||||
|
"descriptionRu": "Полупроводник. Основа стекла, керамики и электроники. Второй по распространённости в земной коре."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "P",
|
||||||
|
"name": "Phosphorus",
|
||||||
|
"nameRu": "Фосфор",
|
||||||
|
"atomicNumber": 15,
|
||||||
|
"atomicMass": 30.974,
|
||||||
|
"electronegativity": 2.19,
|
||||||
|
"category": "nonmetal",
|
||||||
|
"state": "solid",
|
||||||
|
"color": "#ff8000",
|
||||||
|
"description": "White phosphorus glows in the dark and ignites spontaneously. Essential for ATP (biological energy).",
|
||||||
|
"descriptionRu": "Белый фосфор светится в темноте и самовоспламеняется. Необходим для АТФ (биологическая энергия)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "S",
|
||||||
|
"name": "Sulfur",
|
||||||
|
"nameRu": "Сера",
|
||||||
|
"atomicNumber": 16,
|
||||||
|
"atomicMass": 32.065,
|
||||||
|
"electronegativity": 2.58,
|
||||||
|
"category": "nonmetal",
|
||||||
|
"state": "solid",
|
||||||
|
"color": "#ffff30",
|
||||||
|
"description": "Yellow solid with a distinctive smell. Burns with blue flame. Component of gunpowder.",
|
||||||
|
"descriptionRu": "Жёлтое твёрдое вещество с характерным запахом. Горит синим пламенем. Компонент пороха."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "Cl",
|
||||||
|
"name": "Chlorine",
|
||||||
|
"nameRu": "Хлор",
|
||||||
|
"atomicNumber": 17,
|
||||||
|
"atomicMass": 35.453,
|
||||||
|
"electronegativity": 3.16,
|
||||||
|
"category": "halogen",
|
||||||
|
"state": "gas",
|
||||||
|
"color": "#1ff01f",
|
||||||
|
"description": "Toxic yellow-green gas. Powerful disinfectant. Combines readily with metals to form salts.",
|
||||||
|
"descriptionRu": "Ядовитый жёлто-зелёный газ. Мощный дезинфектант. Легко соединяется с металлами, образуя соли."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "K",
|
||||||
|
"name": "Potassium",
|
||||||
|
"nameRu": "Калий",
|
||||||
|
"atomicNumber": 19,
|
||||||
|
"atomicMass": 39.098,
|
||||||
|
"electronegativity": 0.82,
|
||||||
|
"category": "alkali-metal",
|
||||||
|
"state": "solid",
|
||||||
|
"color": "#8f40d4",
|
||||||
|
"description": "Even more reactive than sodium with water — violent purple-flame explosion. Essential nutrient for plants.",
|
||||||
|
"descriptionRu": "Ещё активнее натрия при контакте с водой — бурный взрыв с фиолетовым пламенем. Необходим растениям."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "Ca",
|
||||||
|
"name": "Calcium",
|
||||||
|
"nameRu": "Кальций",
|
||||||
|
"atomicNumber": 20,
|
||||||
|
"atomicMass": 40.078,
|
||||||
|
"electronegativity": 1.00,
|
||||||
|
"category": "alkaline-earth",
|
||||||
|
"state": "solid",
|
||||||
|
"color": "#e0e0e0",
|
||||||
|
"description": "Essential for bones and shells. Reacts with water, but less violently than sodium. Component of limestone and cement.",
|
||||||
|
"descriptionRu": "Необходим для костей и раковин. Реагирует с водой, но менее бурно, чем натрий. Компонент известняка и цемента."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "Fe",
|
||||||
|
"name": "Iron",
|
||||||
|
"nameRu": "Железо",
|
||||||
|
"atomicNumber": 26,
|
||||||
|
"atomicMass": 55.845,
|
||||||
|
"electronegativity": 1.83,
|
||||||
|
"category": "transition-metal",
|
||||||
|
"state": "solid",
|
||||||
|
"color": "#a0a0a0",
|
||||||
|
"description": "Strong, abundant metal. Rusts in moist air. Core of Earth is mostly iron. Magnetic.",
|
||||||
|
"descriptionRu": "Прочный, распространённый металл. Ржавеет на влажном воздухе. Ядро Земли в основном из железа. Магнитен."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "Cu",
|
||||||
|
"name": "Copper",
|
||||||
|
"nameRu": "Медь",
|
||||||
|
"atomicNumber": 29,
|
||||||
|
"atomicMass": 63.546,
|
||||||
|
"electronegativity": 1.90,
|
||||||
|
"category": "transition-metal",
|
||||||
|
"state": "solid",
|
||||||
|
"color": "#c88033",
|
||||||
|
"description": "Excellent conductor of electricity and heat. Turns green (patina) over time. Antibacterial properties.",
|
||||||
|
"descriptionRu": "Отличный проводник электричества и тепла. Зеленеет (патина) со временем. Антибактериальные свойства."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "Zn",
|
||||||
|
"name": "Zinc",
|
||||||
|
"nameRu": "Цинк",
|
||||||
|
"atomicNumber": 30,
|
||||||
|
"atomicMass": 65.38,
|
||||||
|
"electronegativity": 1.65,
|
||||||
|
"category": "transition-metal",
|
||||||
|
"state": "solid",
|
||||||
|
"color": "#7d80b0",
|
||||||
|
"description": "Protects iron from rusting (galvanization). Zinc sulfide glows under UV light. Essential trace nutrient.",
|
||||||
|
"descriptionRu": "Защищает железо от ржавчины (гальванизация). Сульфид цинка светится в УФ-свете. Необходимый микроэлемент."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "Sn",
|
||||||
|
"name": "Tin",
|
||||||
|
"nameRu": "Олово",
|
||||||
|
"atomicNumber": 50,
|
||||||
|
"atomicMass": 118.71,
|
||||||
|
"electronegativity": 1.96,
|
||||||
|
"category": "post-transition-metal",
|
||||||
|
"state": "solid",
|
||||||
|
"color": "#668080",
|
||||||
|
"description": "Soft, malleable metal. Resists corrosion. Used for solder and tin plating. Alloy with copper makes bronze.",
|
||||||
|
"descriptionRu": "Мягкий, ковкий металл. Устойчив к коррозии. Используется для пайки и лужения. Сплав с медью — бронза."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "Au",
|
||||||
|
"name": "Gold",
|
||||||
|
"nameRu": "Золото",
|
||||||
|
"atomicNumber": 79,
|
||||||
|
"atomicMass": 196.967,
|
||||||
|
"electronegativity": 2.54,
|
||||||
|
"category": "transition-metal",
|
||||||
|
"state": "solid",
|
||||||
|
"color": "#ffd123",
|
||||||
|
"description": "Extremely unreactive noble metal. Does not corrode or tarnish. Excellent conductor. Very rare.",
|
||||||
|
"descriptionRu": "Крайне инертный благородный металл. Не корродирует и не тускнеет. Отличный проводник. Очень редок."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "Hg",
|
||||||
|
"name": "Mercury",
|
||||||
|
"nameRu": "Ртуть",
|
||||||
|
"atomicNumber": 80,
|
||||||
|
"atomicMass": 200.592,
|
||||||
|
"electronegativity": 2.00,
|
||||||
|
"category": "transition-metal",
|
||||||
|
"state": "liquid",
|
||||||
|
"color": "#b8b8d0",
|
||||||
|
"description": "Only metal that is liquid at room temperature. Extremely toxic — damages brain and kidneys. Handle with extreme care.",
|
||||||
|
"descriptionRu": "Единственный металл, жидкий при комнатной температуре. Крайне токсичен — поражает мозг и почки. Обращаться с предельной осторожностью."
|
||||||
|
}
|
||||||
|
]
|
||||||
326
src/data/reactions.json
Normal file
326
src/data/reactions.json
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "synth_nacl", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "Na", "count": 1 }, { "id": "Cl", "count": 1 }],
|
||||||
|
"products": [{ "id": "NaCl", "count": 1 }],
|
||||||
|
"energyChange": -40,
|
||||||
|
"description": "Sodium and chlorine combine in a vigorous exothermic reaction to form table salt.",
|
||||||
|
"descriptionRu": "Натрий и хлор соединяются в бурной экзотермической реакции, образуя поваренную соль.",
|
||||||
|
"difficulty": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_kcl", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "K", "count": 1 }, { "id": "Cl", "count": 1 }],
|
||||||
|
"products": [{ "id": "KCl", "count": 1 }],
|
||||||
|
"energyChange": -44,
|
||||||
|
"description": "Potassium bonds with chlorine to form potassium chloride — a valuable fertilizer salt.",
|
||||||
|
"descriptionRu": "Калий связывается с хлором, образуя хлорид калия — ценную удобрительную соль.",
|
||||||
|
"difficulty": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_fes", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "Fe", "count": 1 }, { "id": "S", "count": 1 }],
|
||||||
|
"products": [{ "id": "FeS", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 500 },
|
||||||
|
"energyChange": -10,
|
||||||
|
"description": "Iron and sulfur combine when heated to form iron sulfide (pyrite) — fool's gold that sparks when struck.",
|
||||||
|
"descriptionRu": "Железо и сера при нагревании образуют сульфид железа (пирит) — золото дураков, искрящее при ударе.",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_cus", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "Cu", "count": 1 }, { "id": "S", "count": 1 }],
|
||||||
|
"products": [{ "id": "CuS", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 500 },
|
||||||
|
"energyChange": -5,
|
||||||
|
"description": "Copper reacts with sulfur when heated, forming dark copper sulfide.",
|
||||||
|
"descriptionRu": "Медь реагирует с серой при нагревании, образуя тёмный сульфид меди.",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_zns", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "Zn", "count": 1 }, { "id": "S", "count": 1 }],
|
||||||
|
"products": [{ "id": "ZnS", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 500 },
|
||||||
|
"energyChange": -20,
|
||||||
|
"description": "Zinc and sulfur combine exothermically to form zinc sulfide — a phosphorescent compound that glows in the dark.",
|
||||||
|
"descriptionRu": "Цинк и сера экзотермически образуют сульфид цинка — фосфоресцирующее соединение, светящееся в темноте.",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_h2o", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "H", "count": 2 }, { "id": "O", "count": 1 }],
|
||||||
|
"products": [{ "id": "H2O", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 500 },
|
||||||
|
"energyChange": -70,
|
||||||
|
"description": "Hydrogen combustion. Two parts hydrogen + one part oxygen ignite to produce water, releasing tremendous energy.",
|
||||||
|
"descriptionRu": "Горение водорода. Два объёма водорода + один объём кислорода воспламеняются, образуя воду и выделяя огромную энергию.",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_hcl", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "H", "count": 1 }, { "id": "Cl", "count": 1 }],
|
||||||
|
"products": [{ "id": "HCl", "count": 1 }],
|
||||||
|
"energyChange": -9,
|
||||||
|
"description": "Hydrogen and chlorine combine to form hydrochloric acid — a strong acid that dissolves metals.",
|
||||||
|
"descriptionRu": "Водород и хлор образуют соляную кислоту — сильную кислоту, растворяющую металлы.",
|
||||||
|
"difficulty": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "comb_co2", "type": "combustion",
|
||||||
|
"reactants": [{ "id": "C", "count": 1 }, { "id": "O", "count": 2 }],
|
||||||
|
"products": [{ "id": "CO2", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 500 },
|
||||||
|
"energyChange": -40,
|
||||||
|
"description": "Carbon burns in excess oxygen to produce carbon dioxide. Complete combustion.",
|
||||||
|
"descriptionRu": "Углерод горит в избытке кислорода, образуя углекислый газ. Полное сгорание.",
|
||||||
|
"difficulty": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "comb_co", "type": "combustion",
|
||||||
|
"reactants": [{ "id": "C", "count": 1 }, { "id": "O", "count": 1 }],
|
||||||
|
"products": [{ "id": "CO", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 500 },
|
||||||
|
"energyChange": -11,
|
||||||
|
"description": "Carbon burns in limited oxygen → deadly carbon monoxide. Odorless killer.",
|
||||||
|
"descriptionRu": "Углерод горит при недостатке кислорода → смертельный угарный газ. Убийца без запаха.",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "comb_so2", "type": "combustion",
|
||||||
|
"reactants": [{ "id": "S", "count": 1 }, { "id": "O", "count": 2 }],
|
||||||
|
"products": [{ "id": "SO2", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 300 },
|
||||||
|
"energyChange": -30,
|
||||||
|
"description": "Sulfur burns with a blue flame, producing choking sulfur dioxide gas.",
|
||||||
|
"descriptionRu": "Сера горит синим пламенем, выделяя удушливый сернистый газ.",
|
||||||
|
"difficulty": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "comb_mgo", "type": "combustion",
|
||||||
|
"reactants": [{ "id": "Mg", "count": 1 }, { "id": "O", "count": 1 }],
|
||||||
|
"products": [{ "id": "MgO", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 500 },
|
||||||
|
"energyChange": -60,
|
||||||
|
"description": "Magnesium burns with an intensely bright white flame — blinding! Produces white magnesium oxide ash.",
|
||||||
|
"descriptionRu": "Магний горит ослепительно ярким белым пламенем! Образует белый пепел оксида магния.",
|
||||||
|
"difficulty": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "comb_fe2o3", "type": "combustion",
|
||||||
|
"reactants": [{ "id": "Fe", "count": 2 }, { "id": "O", "count": 3 }],
|
||||||
|
"products": [{ "id": "Fe2O3", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 1000 },
|
||||||
|
"energyChange": -25,
|
||||||
|
"description": "Iron oxidation. At furnace temperatures, iron combines with oxygen to form iron oxide (rust).",
|
||||||
|
"descriptionRu": "Окисление железа. При температуре печи железо соединяется с кислородом, образуя оксид железа (ржавчину).",
|
||||||
|
"difficulty": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "comb_cao", "type": "combustion",
|
||||||
|
"reactants": [{ "id": "Ca", "count": 1 }, { "id": "O", "count": 1 }],
|
||||||
|
"products": [{ "id": "CaO", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 500 },
|
||||||
|
"energyChange": -63,
|
||||||
|
"description": "Calcium burns in oxygen to form quicklime — a powerful desiccant and heat source when mixed with water.",
|
||||||
|
"descriptionRu": "Кальций горит в кислороде, образуя негашёную известь — мощный осушитель и источник тепла при смешении с водой.",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "comb_zno", "type": "combustion",
|
||||||
|
"reactants": [{ "id": "Zn", "count": 1 }, { "id": "O", "count": 1 }],
|
||||||
|
"products": [{ "id": "ZnO", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 500 },
|
||||||
|
"energyChange": -35,
|
||||||
|
"description": "Zinc burns with a blue-green flame, producing zinc oxide — a healing white powder.",
|
||||||
|
"descriptionRu": "Цинк горит сине-зелёным пламенем, образуя оксид цинка — целебный белый порошок.",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "comb_sio2", "type": "combustion",
|
||||||
|
"reactants": [{ "id": "Si", "count": 1 }, { "id": "O", "count": 2 }],
|
||||||
|
"products": [{ "id": "SiO2", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 1000 },
|
||||||
|
"energyChange": -90,
|
||||||
|
"description": "Silicon combines with oxygen at extreme heat to form silica (quartz) — the basis of glass.",
|
||||||
|
"descriptionRu": "Кремний соединяется с кислородом при экстремальном нагреве, образуя кремнезём (кварц) — основу стекла.",
|
||||||
|
"difficulty": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "comb_al2o3", "type": "combustion",
|
||||||
|
"reactants": [{ "id": "Al", "count": 2 }, { "id": "O", "count": 3 }],
|
||||||
|
"products": [{ "id": "Al2O3", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 1000 },
|
||||||
|
"energyChange": -84,
|
||||||
|
"description": "Aluminum combustion produces alumina (corundum) — nearly as hard as diamond.",
|
||||||
|
"descriptionRu": "Горение алюминия производит корунд — почти такой же твёрдый, как алмаз.",
|
||||||
|
"difficulty": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_ch4", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "C", "count": 1 }, { "id": "H", "count": 4 }],
|
||||||
|
"products": [{ "id": "CH4", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 500, "catalyst": "Fe" },
|
||||||
|
"energyChange": -8,
|
||||||
|
"description": "Carbon + hydrogen with iron catalyst → methane. Simplest hydrocarbon fuel.",
|
||||||
|
"descriptionRu": "Углерод + водород с железным катализатором → метан. Простейшее углеводородное топливо.",
|
||||||
|
"difficulty": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_naoh", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "Na", "count": 1 }, { "id": "O", "count": 1 }, { "id": "H", "count": 1 }],
|
||||||
|
"products": [{ "id": "NaOH", "count": 1 }],
|
||||||
|
"energyChange": -45,
|
||||||
|
"description": "Direct synthesis of sodium hydroxide (lye). Extremely caustic — handle carefully!",
|
||||||
|
"descriptionRu": "Прямой синтез гидроксида натрия (щёлочи). Крайне едкий — обращаться осторожно!",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_koh", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "K", "count": 1 }, { "id": "O", "count": 1 }, { "id": "H", "count": 1 }],
|
||||||
|
"products": [{ "id": "KOH", "count": 1 }],
|
||||||
|
"energyChange": -49,
|
||||||
|
"description": "Direct synthesis of potassium hydroxide. Even more reactive than NaOH.",
|
||||||
|
"descriptionRu": "Прямой синтез гидроксида калия. Ещё активнее, чем NaOH.",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_caco3", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "Ca", "count": 1 }, { "id": "C", "count": 1 }, { "id": "O", "count": 3 }],
|
||||||
|
"products": [{ "id": "CaCO3", "count": 1 }],
|
||||||
|
"energyChange": -12,
|
||||||
|
"description": "Calcium, carbon, and oxygen form calcium carbonate — the building block of limestone and shells.",
|
||||||
|
"descriptionRu": "Кальций, углерод и кислород образуют карбонат кальция — строительный блок известняка и ракушек.",
|
||||||
|
"difficulty": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_nahco3", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "Na", "count": 1 }, { "id": "H", "count": 1 }, { "id": "C", "count": 1 }, { "id": "O", "count": 3 }],
|
||||||
|
"products": [{ "id": "NaHCO3", "count": 1 }],
|
||||||
|
"energyChange": -10,
|
||||||
|
"description": "Synthesis of sodium bicarbonate (baking soda). Versatile acid neutralizer.",
|
||||||
|
"descriptionRu": "Синтез гидрокарбоната натрия (пищевая сода). Универсальный нейтрализатор кислот.",
|
||||||
|
"difficulty": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_kno3", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "K", "count": 1 }, { "id": "N", "count": 1 }, { "id": "O", "count": 3 }],
|
||||||
|
"products": [{ "id": "KNO3", "count": 1 }],
|
||||||
|
"energyChange": -5,
|
||||||
|
"description": "Potassium nitrate (saltpeter). A powerful oxidizer — one third of gunpowder.",
|
||||||
|
"descriptionRu": "Нитрат калия (селитра). Мощный окислитель — треть состава пороха.",
|
||||||
|
"difficulty": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_caoh2", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "Ca", "count": 1 }, { "id": "O", "count": 2 }, { "id": "H", "count": 2 }],
|
||||||
|
"products": [{ "id": "CaOH2", "count": 1 }],
|
||||||
|
"energyChange": -15,
|
||||||
|
"description": "Calcium hydroxide (slaked lime). Essential for mortar and water purification.",
|
||||||
|
"descriptionRu": "Гидроксид кальция (гашёная известь). Необходим для строительного раствора и очистки воды.",
|
||||||
|
"difficulty": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "repl_na_h2o", "type": "single-replacement",
|
||||||
|
"reactants": [{ "id": "Na", "count": 1 }, { "id": "H2O", "count": 1 }],
|
||||||
|
"products": [{ "id": "NaOH", "count": 1 }, { "id": "H", "count": 1 }],
|
||||||
|
"energyChange": -80,
|
||||||
|
"description": "VIOLENT! Sodium reacts explosively with water → lye + hydrogen gas + fire. Never drop sodium in water!",
|
||||||
|
"descriptionRu": "ОПАСНО! Натрий взрывоподобно реагирует с водой → щёлочь + водород + пламя. Никогда не бросайте натрий в воду!",
|
||||||
|
"difficulty": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "repl_k_h2o", "type": "single-replacement",
|
||||||
|
"reactants": [{ "id": "K", "count": 1 }, { "id": "H2O", "count": 1 }],
|
||||||
|
"products": [{ "id": "KOH", "count": 1 }, { "id": "H", "count": 1 }],
|
||||||
|
"energyChange": -95,
|
||||||
|
"description": "EXPLOSIVE! Potassium reacts even more violently than sodium with water — purple flame, detonation!",
|
||||||
|
"descriptionRu": "ВЗРЫВ! Калий реагирует с водой ещё бурнее натрия — фиолетовое пламя, детонация!",
|
||||||
|
"difficulty": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "repl_cao_h2o", "type": "single-replacement",
|
||||||
|
"reactants": [{ "id": "CaO", "count": 1 }, { "id": "H2O", "count": 1 }],
|
||||||
|
"products": [{ "id": "CaOH2", "count": 1 }],
|
||||||
|
"energyChange": -65,
|
||||||
|
"description": "Quicklime + water → slaked lime + intense heat. Can boil water! Used in ancient warfare.",
|
||||||
|
"descriptionRu": "Негашёная известь + вода → гашёная известь + сильный нагрев. Может вскипятить воду! Использовалось в древних войнах.",
|
||||||
|
"difficulty": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ab_naoh_hcl", "type": "acid-base",
|
||||||
|
"reactants": [{ "id": "NaOH", "count": 1 }, { "id": "HCl", "count": 1 }],
|
||||||
|
"products": [{ "id": "NaCl", "count": 1 }, { "id": "H2O", "count": 1 }],
|
||||||
|
"energyChange": -57,
|
||||||
|
"description": "Classic neutralization: acid + base → salt + water. NaOH + HCl → NaCl + H₂O.",
|
||||||
|
"descriptionRu": "Классическая нейтрализация: кислота + основание → соль + вода. NaOH + HCl → NaCl + H₂O.",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ab_koh_hcl", "type": "acid-base",
|
||||||
|
"reactants": [{ "id": "KOH", "count": 1 }, { "id": "HCl", "count": 1 }],
|
||||||
|
"products": [{ "id": "KCl", "count": 1 }, { "id": "H2O", "count": 1 }],
|
||||||
|
"energyChange": -57,
|
||||||
|
"description": "Neutralization: potassium hydroxide + hydrochloric acid → potassium chloride + water.",
|
||||||
|
"descriptionRu": "Нейтрализация: гидроксид калия + соляная кислота → хлорид калия + вода.",
|
||||||
|
"difficulty": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ab_nahco3_hcl", "type": "acid-base",
|
||||||
|
"reactants": [{ "id": "NaHCO3", "count": 1 }, { "id": "HCl", "count": 1 }],
|
||||||
|
"products": [{ "id": "NaCl", "count": 1 }, { "id": "H2O", "count": 1 }, { "id": "CO2", "count": 1 }],
|
||||||
|
"energyChange": -12,
|
||||||
|
"description": "Baking soda fizzes violently with acid! Produces salt, water, and carbon dioxide gas. Classic volcano reaction.",
|
||||||
|
"descriptionRu": "Сода бурно шипит при контакте с кислотой! Образуется соль, вода и углекислый газ. Классическая реакция «вулкан».",
|
||||||
|
"difficulty": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_gunpowder", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "KNO3", "count": 1 }, { "id": "S", "count": 1 }, { "id": "C", "count": 1 }],
|
||||||
|
"products": [{ "id": "GUNPOWDER", "count": 1 }],
|
||||||
|
"energyChange": 0,
|
||||||
|
"description": "The legendary mixture: saltpeter + sulfur + charcoal = black gunpowder. Handle with extreme care.",
|
||||||
|
"descriptionRu": "Легендарная смесь: селитра + сера + уголь = чёрный порох. Обращаться с предельной осторожностью.",
|
||||||
|
"difficulty": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "redox_thermite", "type": "redox",
|
||||||
|
"reactants": [{ "id": "Fe2O3", "count": 1 }, { "id": "Al", "count": 2 }],
|
||||||
|
"products": [{ "id": "Fe", "count": 2 }, { "id": "Al2O3", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 1000 },
|
||||||
|
"energyChange": -100,
|
||||||
|
"description": "THERMITE! Iron oxide + aluminum → molten iron + alumina. Temperature exceeds 2500°C. Melts through anything.",
|
||||||
|
"descriptionRu": "ТЕРМИТ! Оксид железа + алюминий → расплавленное железо + корунд. Температура превышает 2500°C. Прожигает всё.",
|
||||||
|
"difficulty": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synth_ethanol", "type": "synthesis",
|
||||||
|
"reactants": [{ "id": "C", "count": 2 }, { "id": "H", "count": 6 }, { "id": "O", "count": 1 }],
|
||||||
|
"products": [{ "id": "C2H5OH", "count": 1 }],
|
||||||
|
"conditions": { "catalyst": "Cu" },
|
||||||
|
"energyChange": -3,
|
||||||
|
"description": "Ethanol synthesis requires a copper catalyst. The original chemistry of civilization — fermentation.",
|
||||||
|
"descriptionRu": "Синтез этанола требует медного катализатора. Изначальная химия цивилизации — ферментация.",
|
||||||
|
"difficulty": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "decomp_h2o", "type": "decomposition",
|
||||||
|
"reactants": [{ "id": "H2O", "count": 1 }],
|
||||||
|
"products": [{ "id": "H", "count": 2 }, { "id": "O", "count": 1 }],
|
||||||
|
"conditions": { "requiresEnergy": true },
|
||||||
|
"energyChange": 70,
|
||||||
|
"description": "Electrolysis: electric current splits water into hydrogen and oxygen. Endothermic — needs energy input.",
|
||||||
|
"descriptionRu": "Электролиз: электрический ток разлагает воду на водород и кислород. Эндотермическая — требует энергии.",
|
||||||
|
"difficulty": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "decomp_caco3", "type": "decomposition",
|
||||||
|
"reactants": [{ "id": "CaCO3", "count": 1 }],
|
||||||
|
"products": [{ "id": "CaO", "count": 1 }, { "id": "CO2", "count": 1 }],
|
||||||
|
"conditions": { "minTemp": 1000 },
|
||||||
|
"energyChange": 18,
|
||||||
|
"description": "Lime burning: heating limestone drives off CO₂, leaving quicklime. Ancient building technology.",
|
||||||
|
"descriptionRu": "Обжиг извести: нагрев известняка выгоняет CO₂, оставляя негашёную известь. Древняя строительная технология.",
|
||||||
|
"difficulty": 3
|
||||||
|
}
|
||||||
|
]
|
||||||
80
src/ecs/bridge.ts
Normal file
80
src/ecs/bridge.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import Phaser from 'phaser';
|
||||||
|
import { query } from 'bitecs';
|
||||||
|
import type { World } from './world';
|
||||||
|
import { Position, SpriteRef } from './components';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phaser ↔ bitECS sync bridge
|
||||||
|
*
|
||||||
|
* Manages Phaser GameObjects based on ECS entity state:
|
||||||
|
* - Creates circles for new entities with Position + SpriteRef
|
||||||
|
* - Destroys circles for entities that no longer exist
|
||||||
|
* - Syncs ECS Position → Phaser sprite coordinates every frame
|
||||||
|
*/
|
||||||
|
export class PhaserBridge {
|
||||||
|
private scene: Phaser.Scene;
|
||||||
|
private spriteMap = new Map<number, Phaser.GameObjects.Arc>();
|
||||||
|
|
||||||
|
constructor(scene: Phaser.Scene) {
|
||||||
|
this.scene = scene;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync ECS state to Phaser — call once per frame
|
||||||
|
* Handles creation, destruction, and position updates
|
||||||
|
*/
|
||||||
|
sync(world: World): void {
|
||||||
|
const entities = query(world, [Position, SpriteRef]);
|
||||||
|
const activeSet = new Set<number>();
|
||||||
|
|
||||||
|
for (const eid of entities) {
|
||||||
|
activeSet.add(eid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove sprites for entities that no longer exist
|
||||||
|
const toRemove: number[] = [];
|
||||||
|
for (const eid of this.spriteMap.keys()) {
|
||||||
|
if (!activeSet.has(eid)) {
|
||||||
|
toRemove.push(eid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const eid of toRemove) {
|
||||||
|
const sprite = this.spriteMap.get(eid);
|
||||||
|
if (sprite) {
|
||||||
|
sprite.destroy();
|
||||||
|
}
|
||||||
|
this.spriteMap.delete(eid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create sprites for new entities + update positions for all
|
||||||
|
for (const eid of entities) {
|
||||||
|
let sprite = this.spriteMap.get(eid);
|
||||||
|
|
||||||
|
if (!sprite) {
|
||||||
|
sprite = this.scene.add.circle(
|
||||||
|
Position.x[eid],
|
||||||
|
Position.y[eid],
|
||||||
|
SpriteRef.radius[eid],
|
||||||
|
SpriteRef.color[eid],
|
||||||
|
);
|
||||||
|
this.spriteMap.set(eid, sprite);
|
||||||
|
}
|
||||||
|
|
||||||
|
sprite.x = Position.x[eid];
|
||||||
|
sprite.y = Position.y[eid];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current number of rendered entities */
|
||||||
|
get entityCount(): number {
|
||||||
|
return this.spriteMap.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clean up all sprites */
|
||||||
|
destroy(): void {
|
||||||
|
for (const sprite of this.spriteMap.values()) {
|
||||||
|
sprite.destroy();
|
||||||
|
}
|
||||||
|
this.spriteMap.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/ecs/components.ts
Normal file
36
src/ecs/components.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* ECS Components — plain objects with number arrays (bitECS 0.4 pattern)
|
||||||
|
*
|
||||||
|
* Components define the data schema for entities.
|
||||||
|
* Systems read/write component data.
|
||||||
|
* Bridge syncs component data to Phaser rendering.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** World position in pixels */
|
||||||
|
export const Position = {
|
||||||
|
x: [] as number[],
|
||||||
|
y: [] as number[],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Movement velocity in pixels per second */
|
||||||
|
export const Velocity = {
|
||||||
|
vx: [] as number[],
|
||||||
|
vy: [] as number[],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Visual representation — used by bridge to create/update Phaser objects */
|
||||||
|
export const SpriteRef = {
|
||||||
|
color: [] as number[], // hex color (e.g. 0x00ff88)
|
||||||
|
radius: [] as number[], // circle radius in pixels
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Entity health — damage, healing, death */
|
||||||
|
export const Health = {
|
||||||
|
current: [] as number[],
|
||||||
|
max: [] as number[],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Link to chemistry system — stores atomic number of primary element */
|
||||||
|
export const ChemicalComposition = {
|
||||||
|
primaryElement: [] as number[], // atomic number (e.g. 11 for Na)
|
||||||
|
};
|
||||||
62
src/ecs/factory.ts
Normal file
62
src/ecs/factory.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { addEntity, addComponent, removeEntity } from 'bitecs';
|
||||||
|
import type { World } from './world';
|
||||||
|
import {
|
||||||
|
Position,
|
||||||
|
Velocity,
|
||||||
|
Health,
|
||||||
|
SpriteRef,
|
||||||
|
ChemicalComposition,
|
||||||
|
} from './components';
|
||||||
|
|
||||||
|
/** Configuration for creating a game entity */
|
||||||
|
export interface EntityConfig {
|
||||||
|
position?: { x: number; y: number };
|
||||||
|
velocity?: { vx: number; vy: number };
|
||||||
|
health?: { current: number; max: number };
|
||||||
|
sprite?: { color: number; radius: number };
|
||||||
|
chemicalElement?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a game entity with specified components
|
||||||
|
* @returns entity ID (eid)
|
||||||
|
*/
|
||||||
|
export function createGameEntity(world: World, config: EntityConfig): number {
|
||||||
|
const eid = addEntity(world);
|
||||||
|
|
||||||
|
if (config.position !== undefined) {
|
||||||
|
addComponent(world, eid, Position);
|
||||||
|
Position.x[eid] = config.position.x;
|
||||||
|
Position.y[eid] = config.position.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.velocity !== undefined) {
|
||||||
|
addComponent(world, eid, Velocity);
|
||||||
|
Velocity.vx[eid] = config.velocity.vx;
|
||||||
|
Velocity.vy[eid] = config.velocity.vy;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.health !== undefined) {
|
||||||
|
addComponent(world, eid, Health);
|
||||||
|
Health.current[eid] = config.health.current;
|
||||||
|
Health.max[eid] = config.health.max;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.sprite !== undefined) {
|
||||||
|
addComponent(world, eid, SpriteRef);
|
||||||
|
SpriteRef.color[eid] = config.sprite.color;
|
||||||
|
SpriteRef.radius[eid] = config.sprite.radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.chemicalElement !== undefined) {
|
||||||
|
addComponent(world, eid, ChemicalComposition);
|
||||||
|
ChemicalComposition.primaryElement[eid] = config.chemicalElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return eid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a game entity and all its components from the world */
|
||||||
|
export function removeGameEntity(world: World, eid: number): void {
|
||||||
|
removeEntity(world, eid);
|
||||||
|
}
|
||||||
30
src/ecs/systems/health.ts
Normal file
30
src/ecs/systems/health.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { query } from 'bitecs';
|
||||||
|
import type { World } from '../world';
|
||||||
|
import { Health } from '../components';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health system — detects entities with health ≤ 0
|
||||||
|
* @returns array of entity IDs that should be removed (dead)
|
||||||
|
*/
|
||||||
|
export function healthSystem(world: World): number[] {
|
||||||
|
const deadEntities: number[] = [];
|
||||||
|
for (const eid of query(world, [Health])) {
|
||||||
|
if (Health.current[eid] <= 0) {
|
||||||
|
deadEntities.push(eid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deadEntities;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply damage to entity — reduces current health */
|
||||||
|
export function applyDamage(eid: number, amount: number): void {
|
||||||
|
Health.current[eid] -= amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply healing to entity — increases current health, capped at max */
|
||||||
|
export function applyHealing(eid: number, amount: number): void {
|
||||||
|
Health.current[eid] = Math.min(
|
||||||
|
Health.current[eid] + amount,
|
||||||
|
Health.max[eid],
|
||||||
|
);
|
||||||
|
}
|
||||||
39
src/ecs/systems/movement.ts
Normal file
39
src/ecs/systems/movement.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { query } from 'bitecs';
|
||||||
|
import type { World } from '../world';
|
||||||
|
import { Position, Velocity } from '../components';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Movement system — updates Position by Velocity * delta
|
||||||
|
* Velocities are in pixels/second, delta is in milliseconds
|
||||||
|
*/
|
||||||
|
export function movementSystem(world: World, deltaMs: number): void {
|
||||||
|
const dt = deltaMs / 1000;
|
||||||
|
for (const eid of query(world, [Position, Velocity])) {
|
||||||
|
Position.x[eid] += Velocity.vx[eid] * dt;
|
||||||
|
Position.y[eid] += Velocity.vy[eid] * dt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bounce system — reverses velocity when entity hits screen bounds
|
||||||
|
* Ensures velocity always points away from boundary
|
||||||
|
*/
|
||||||
|
export function bounceSystem(world: World, width: number, height: number): void {
|
||||||
|
for (const eid of query(world, [Position, Velocity])) {
|
||||||
|
if (Position.x[eid] < 0) {
|
||||||
|
Velocity.vx[eid] = Math.abs(Velocity.vx[eid]);
|
||||||
|
Position.x[eid] = 0;
|
||||||
|
} else if (Position.x[eid] > width) {
|
||||||
|
Velocity.vx[eid] = -Math.abs(Velocity.vx[eid]);
|
||||||
|
Position.x[eid] = width;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Position.y[eid] < 0) {
|
||||||
|
Velocity.vy[eid] = Math.abs(Velocity.vy[eid]);
|
||||||
|
Position.y[eid] = 0;
|
||||||
|
} else if (Position.y[eid] > height) {
|
||||||
|
Velocity.vy[eid] = -Math.abs(Velocity.vy[eid]);
|
||||||
|
Position.y[eid] = height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/ecs/world.ts
Normal file
39
src/ecs/world.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { createWorld } from 'bitecs';
|
||||||
|
|
||||||
|
/** bitECS world type */
|
||||||
|
export type World = ReturnType<typeof createWorld>;
|
||||||
|
|
||||||
|
/** Time tracking for game loop */
|
||||||
|
export interface GameTime {
|
||||||
|
/** Milliseconds since last frame */
|
||||||
|
delta: number;
|
||||||
|
/** Total milliseconds elapsed */
|
||||||
|
elapsed: number;
|
||||||
|
/** Frame counter */
|
||||||
|
tick: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Game world = bitECS world + time tracking */
|
||||||
|
export interface GameWorld {
|
||||||
|
world: World;
|
||||||
|
time: GameTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a new game world with zeroed time */
|
||||||
|
export function createGameWorld(): GameWorld {
|
||||||
|
return {
|
||||||
|
world: createWorld(),
|
||||||
|
time: {
|
||||||
|
delta: 0,
|
||||||
|
elapsed: 0,
|
||||||
|
tick: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update time tracking — call once per frame with Phaser's delta (ms) */
|
||||||
|
export function updateTime(gameWorld: GameWorld, deltaMs: number): void {
|
||||||
|
gameWorld.time.delta = deltaMs;
|
||||||
|
gameWorld.time.elapsed += deltaMs;
|
||||||
|
gameWorld.time.tick += 1;
|
||||||
|
}
|
||||||
@@ -30,29 +30,33 @@ export class BootScene extends Phaser.Scene {
|
|||||||
|
|
||||||
// Version
|
// Version
|
||||||
this.add
|
this.add
|
||||||
.text(cx, cy + 80, 'v0.1.0 — Phase 0: Project Setup', {
|
.text(cx, cy + 80, 'v0.2.0 — Phase 2: ECS Foundation', {
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
color: '#333333',
|
color: '#333333',
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
})
|
})
|
||||||
.setOrigin(0.5);
|
.setOrigin(0.5);
|
||||||
|
|
||||||
// Pulsing indicator
|
// Click to start
|
||||||
const dot = this.add
|
const startText = this.add
|
||||||
.text(cx, cy + 120, '◉', {
|
.text(cx, cy + 120, '[ Click to start ]', {
|
||||||
fontSize: '24px',
|
fontSize: '16px',
|
||||||
color: '#00ff88',
|
color: '#00ff88',
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
})
|
})
|
||||||
.setOrigin(0.5);
|
.setOrigin(0.5);
|
||||||
|
|
||||||
this.tweens.add({
|
this.tweens.add({
|
||||||
targets: dot,
|
targets: startText,
|
||||||
alpha: 0.2,
|
alpha: 0.3,
|
||||||
duration: 1500,
|
duration: 1500,
|
||||||
yoyo: true,
|
yoyo: true,
|
||||||
repeat: -1,
|
repeat: -1,
|
||||||
ease: 'Sine.easeInOut',
|
ease: 'Sine.easeInOut',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.input.once('pointerdown', () => {
|
||||||
|
this.scene.start('GameScene');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
83
src/scenes/GameScene.ts
Normal file
83
src/scenes/GameScene.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import Phaser from 'phaser';
|
||||||
|
import { createGameWorld, updateTime, type GameWorld } from '../ecs/world';
|
||||||
|
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 type { BiomeData } from '../world/types';
|
||||||
|
import { generateWorld } from '../world/generator';
|
||||||
|
import { createWorldTilemap } from '../world/tilemap';
|
||||||
|
import { setupCamera, updateCamera, type CameraKeys } from '../world/camera';
|
||||||
|
import { Minimap } from '../world/minimap';
|
||||||
|
|
||||||
|
export class GameScene extends Phaser.Scene {
|
||||||
|
private gameWorld!: GameWorld;
|
||||||
|
private bridge!: PhaserBridge;
|
||||||
|
private cameraKeys!: CameraKeys;
|
||||||
|
private minimap!: Minimap;
|
||||||
|
private statsText!: Phaser.GameObjects.Text;
|
||||||
|
private worldSeed!: number;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super({ key: 'GameScene' });
|
||||||
|
}
|
||||||
|
|
||||||
|
create(): void {
|
||||||
|
// 1. Initialize ECS (needed for future entity systems)
|
||||||
|
this.gameWorld = createGameWorld();
|
||||||
|
this.bridge = new PhaserBridge(this);
|
||||||
|
|
||||||
|
// 2. Generate world
|
||||||
|
const biome = biomeDataArray[0] as BiomeData;
|
||||||
|
this.worldSeed = Date.now() % 1000000;
|
||||||
|
const worldData = generateWorld(biome, this.worldSeed);
|
||||||
|
|
||||||
|
// 3. Create tilemap
|
||||||
|
createWorldTilemap(this, worldData);
|
||||||
|
|
||||||
|
// 4. Camera with bounds and WASD controls
|
||||||
|
const worldPixelW = biome.mapWidth * biome.tileSize;
|
||||||
|
const worldPixelH = biome.mapHeight * biome.tileSize;
|
||||||
|
this.cameraKeys = setupCamera(this, worldPixelW, worldPixelH);
|
||||||
|
|
||||||
|
// 5. Minimap
|
||||||
|
this.minimap = new Minimap(this, worldData);
|
||||||
|
|
||||||
|
// 6. UI overlay
|
||||||
|
this.statsText = this.add.text(10, 10, '', {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#00ff88',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
backgroundColor: '#000000aa',
|
||||||
|
padding: { x: 4, y: 2 },
|
||||||
|
});
|
||||||
|
this.statsText.setScrollFactor(0);
|
||||||
|
this.statsText.setDepth(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(_time: number, delta: number): void {
|
||||||
|
// 1. Update world time
|
||||||
|
updateTime(this.gameWorld, delta);
|
||||||
|
|
||||||
|
// 2. Camera movement
|
||||||
|
updateCamera(this, this.cameraKeys, delta);
|
||||||
|
|
||||||
|
// 3. ECS systems (no entities yet — future phases will add player, creatures)
|
||||||
|
movementSystem(this.gameWorld.world, delta);
|
||||||
|
const dead = healthSystem(this.gameWorld.world);
|
||||||
|
for (const eid of dead) {
|
||||||
|
removeGameEntity(this.gameWorld.world, eid);
|
||||||
|
}
|
||||||
|
this.bridge.sync(this.gameWorld.world);
|
||||||
|
|
||||||
|
// 4. Minimap viewport
|
||||||
|
this.minimap.update(this.cameras.main);
|
||||||
|
|
||||||
|
// 5. Stats
|
||||||
|
const fps = delta > 0 ? Math.round(1000 / delta) : 0;
|
||||||
|
this.statsText.setText(
|
||||||
|
`seed: ${this.worldSeed} | ${fps} fps | WASD move, scroll zoom`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/world/camera.ts
Normal file
69
src/world/camera.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import Phaser from 'phaser';
|
||||||
|
|
||||||
|
/** Keyboard keys for camera movement */
|
||||||
|
export interface CameraKeys {
|
||||||
|
up: Phaser.Input.Keyboard.Key;
|
||||||
|
down: Phaser.Input.Keyboard.Key;
|
||||||
|
left: Phaser.Input.Keyboard.Key;
|
||||||
|
right: Phaser.Input.Keyboard.Key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up camera with bounds, zoom, and WASD movement
|
||||||
|
* (Temporary controls — replaced by player follow in Phase 4)
|
||||||
|
*/
|
||||||
|
export function setupCamera(
|
||||||
|
scene: Phaser.Scene,
|
||||||
|
worldPixelWidth: number,
|
||||||
|
worldPixelHeight: number,
|
||||||
|
): CameraKeys {
|
||||||
|
const camera = scene.cameras.main;
|
||||||
|
camera.setBounds(0, 0, worldPixelWidth, worldPixelHeight);
|
||||||
|
camera.setZoom(1);
|
||||||
|
|
||||||
|
// Start centered
|
||||||
|
camera.scrollX = (worldPixelWidth - camera.width) / 2;
|
||||||
|
camera.scrollY = (worldPixelHeight - camera.height) / 2;
|
||||||
|
|
||||||
|
// WASD keys
|
||||||
|
const keyboard = scene.input.keyboard;
|
||||||
|
if (!keyboard) {
|
||||||
|
throw new Error('Keyboard plugin not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys: CameraKeys = {
|
||||||
|
up: keyboard.addKey('W'),
|
||||||
|
down: keyboard.addKey('S'),
|
||||||
|
left: keyboard.addKey('A'),
|
||||||
|
right: keyboard.addKey('D'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update camera position based on WASD keys — call each frame */
|
||||||
|
export function updateCamera(
|
||||||
|
scene: Phaser.Scene,
|
||||||
|
keys: CameraKeys,
|
||||||
|
deltaMs: number,
|
||||||
|
): void {
|
||||||
|
const camera = scene.cameras.main;
|
||||||
|
const speed = 300 / camera.zoom; // faster when zoomed out
|
||||||
|
const dt = deltaMs / 1000;
|
||||||
|
|
||||||
|
if (keys.left.isDown) camera.scrollX -= speed * dt;
|
||||||
|
if (keys.right.isDown) camera.scrollX += speed * dt;
|
||||||
|
if (keys.up.isDown) camera.scrollY -= speed * dt;
|
||||||
|
if (keys.down.isDown) camera.scrollY += speed * dt;
|
||||||
|
}
|
||||||
67
src/world/generator.ts
Normal file
67
src/world/generator.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import type { BiomeData, TileGrid, WorldData } from './types';
|
||||||
|
import { createSeededNoise, sampleNoise, type Noise2D } from './noise';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a world grid from biome data and a seed
|
||||||
|
*
|
||||||
|
* Algorithm:
|
||||||
|
* 1. Elevation noise → base terrain type (acid pools low, crystals high)
|
||||||
|
* 2. Detail noise → sparse overlay (geysers near acid, minerals on ground)
|
||||||
|
* 3. Each tile deterministically chosen from noise values
|
||||||
|
*/
|
||||||
|
export function generateWorld(biome: BiomeData, seed: number): WorldData {
|
||||||
|
const elevationNoise = createSeededNoise(seed);
|
||||||
|
const detailNoise = createSeededNoise(seed + 7919); // prime offset for independence
|
||||||
|
|
||||||
|
const grid: TileGrid = [];
|
||||||
|
|
||||||
|
for (let y = 0; y < biome.mapHeight; y++) {
|
||||||
|
const row: number[] = [];
|
||||||
|
for (let x = 0; x < biome.mapWidth; x++) {
|
||||||
|
const elevation = sampleNoise(elevationNoise, x, y, biome.generation.elevationScale);
|
||||||
|
const detail = sampleNoise(detailNoise, x, y, biome.generation.detailScale);
|
||||||
|
row.push(determineTile(elevation, detail, biome));
|
||||||
|
}
|
||||||
|
grid.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { grid, biome, seed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine tile type from noise values
|
||||||
|
*
|
||||||
|
* Base tile from elevation thresholds, then overlay specials:
|
||||||
|
* - Geysers spawn on acid-shallow tiles with very high detail noise
|
||||||
|
* - Mineral veins spawn on walkable ground with high detail noise
|
||||||
|
*/
|
||||||
|
function determineTile(elevation: number, detail: number, biome: BiomeData): number {
|
||||||
|
const gen = biome.generation;
|
||||||
|
|
||||||
|
// Base tile from elevation rules (first matching threshold)
|
||||||
|
let baseTileId = gen.elevationRules[gen.elevationRules.length - 1].tileId;
|
||||||
|
for (const rule of gen.elevationRules) {
|
||||||
|
if (elevation < rule.below) {
|
||||||
|
baseTileId = rule.tileId;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Geyser overlay: on acid-shallow + very high detail noise
|
||||||
|
if (baseTileId === gen.geyserOnTile && detail > gen.geyserThreshold) {
|
||||||
|
return findTileIdByName(biome, 'geyser');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mineral overlay: on walkable ground + high detail noise
|
||||||
|
if (gen.mineralOnTiles.includes(baseTileId) && detail > gen.mineralThreshold) {
|
||||||
|
return findTileIdByName(biome, 'mineral-vein');
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseTileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find tile ID by name, falling back to 0 if not found */
|
||||||
|
function findTileIdByName(biome: BiomeData, name: string): number {
|
||||||
|
const tile = biome.tiles.find(t => t.name === name);
|
||||||
|
return tile ? tile.id : 0;
|
||||||
|
}
|
||||||
126
src/world/minimap.ts
Normal file
126
src/world/minimap.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import Phaser from 'phaser';
|
||||||
|
import type { TileData, TileGrid, WorldData } from './types';
|
||||||
|
|
||||||
|
const MINIMAP_DEPTH = 100;
|
||||||
|
const VIEWPORT_DEPTH = 101;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimap — small overview of the entire world in the top-right corner
|
||||||
|
*
|
||||||
|
* Shows tile colors at reduced scale with a white rectangle
|
||||||
|
* indicating the camera's current viewport
|
||||||
|
*/
|
||||||
|
export class Minimap {
|
||||||
|
private image: Phaser.GameObjects.Image;
|
||||||
|
private viewport: Phaser.GameObjects.Graphics;
|
||||||
|
private border: Phaser.GameObjects.Graphics;
|
||||||
|
private mapWidth: number;
|
||||||
|
private mapHeight: number;
|
||||||
|
private tileSize: number;
|
||||||
|
private minimapScale: number;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
scene: Phaser.Scene,
|
||||||
|
worldData: WorldData,
|
||||||
|
scale: number = 2,
|
||||||
|
) {
|
||||||
|
const { grid, biome } = worldData;
|
||||||
|
this.mapWidth = biome.mapWidth;
|
||||||
|
this.mapHeight = biome.mapHeight;
|
||||||
|
this.tileSize = biome.tileSize;
|
||||||
|
this.minimapScale = scale;
|
||||||
|
|
||||||
|
const minimapW = this.mapWidth * scale;
|
||||||
|
const minimapH = this.mapHeight * scale;
|
||||||
|
|
||||||
|
// Generate minimap texture
|
||||||
|
this.createMinimapTexture(scene, grid, biome.tiles, scale);
|
||||||
|
|
||||||
|
// Position in top-right corner
|
||||||
|
const screenW = scene.cameras.main.width;
|
||||||
|
const padding = 10;
|
||||||
|
const right = screenW - padding;
|
||||||
|
const top = padding;
|
||||||
|
|
||||||
|
// Border
|
||||||
|
this.border = scene.add.graphics();
|
||||||
|
this.border.setScrollFactor(0);
|
||||||
|
this.border.setDepth(MINIMAP_DEPTH);
|
||||||
|
this.border.lineStyle(2, 0x00ff88, 0.6);
|
||||||
|
this.border.strokeRect(
|
||||||
|
right - minimapW - 1,
|
||||||
|
top - 1,
|
||||||
|
minimapW + 2,
|
||||||
|
minimapH + 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Semi-transparent background
|
||||||
|
this.border.fillStyle(0x000000, 0.4);
|
||||||
|
this.border.fillRect(right - minimapW, top, minimapW, minimapH);
|
||||||
|
|
||||||
|
// Minimap image
|
||||||
|
this.image = scene.add.image(right, top, 'minimap');
|
||||||
|
this.image.setOrigin(1, 0);
|
||||||
|
this.image.setScrollFactor(0);
|
||||||
|
this.image.setDepth(MINIMAP_DEPTH);
|
||||||
|
|
||||||
|
// Viewport indicator
|
||||||
|
this.viewport = scene.add.graphics();
|
||||||
|
this.viewport.setScrollFactor(0);
|
||||||
|
this.viewport.setDepth(VIEWPORT_DEPTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create canvas texture for minimap (1 pixel per tile * scale) */
|
||||||
|
private createMinimapTexture(
|
||||||
|
scene: Phaser.Scene,
|
||||||
|
grid: TileGrid,
|
||||||
|
tiles: TileData[],
|
||||||
|
scale: number,
|
||||||
|
): void {
|
||||||
|
const h = grid.length;
|
||||||
|
const w = grid[0].length;
|
||||||
|
const canvasW = w * scale;
|
||||||
|
const canvasH = h * scale;
|
||||||
|
|
||||||
|
const canvasTexture = scene.textures.createCanvas('minimap', canvasW, canvasH);
|
||||||
|
const ctx = canvasTexture.getContext();
|
||||||
|
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
const tileId = grid[y][x];
|
||||||
|
ctx.fillStyle = tiles[tileId].color;
|
||||||
|
ctx.fillRect(x * scale, y * scale, scale, scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
canvasTexture.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update the viewport indicator rectangle — call each frame */
|
||||||
|
update(camera: Phaser.Cameras.Scene2D.Camera): void {
|
||||||
|
const screenW = camera.width;
|
||||||
|
const padding = 10;
|
||||||
|
const minimapW = this.mapWidth * this.minimapScale;
|
||||||
|
const minimapX = screenW - padding - minimapW;
|
||||||
|
const minimapY = padding;
|
||||||
|
|
||||||
|
// Camera's visible area in world coordinates
|
||||||
|
const worldView = camera.worldView;
|
||||||
|
|
||||||
|
// Convert world coordinates to minimap coordinates
|
||||||
|
const viewX = minimapX + (worldView.x / this.tileSize) * this.minimapScale;
|
||||||
|
const viewY = minimapY + (worldView.y / this.tileSize) * this.minimapScale;
|
||||||
|
const viewW = (worldView.width / this.tileSize) * this.minimapScale;
|
||||||
|
const viewH = (worldView.height / this.tileSize) * this.minimapScale;
|
||||||
|
|
||||||
|
this.viewport.clear();
|
||||||
|
this.viewport.lineStyle(1, 0xffffff, 0.8);
|
||||||
|
this.viewport.strokeRect(viewX, viewY, viewW, viewH);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.image.destroy();
|
||||||
|
this.viewport.destroy();
|
||||||
|
this.border.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/world/noise.ts
Normal file
39
src/world/noise.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { createNoise2D } from 'simplex-noise';
|
||||||
|
|
||||||
|
/** 2D noise function returning values in [-1, 1] */
|
||||||
|
export type Noise2D = (x: number, y: number) => number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mulberry32 — fast, seedable 32-bit PRNG
|
||||||
|
* Returns values in [0, 1)
|
||||||
|
*/
|
||||||
|
function mulberry32(seed: number): () => number {
|
||||||
|
let state = seed | 0;
|
||||||
|
return () => {
|
||||||
|
state = (state + 0x6d2b79f5) | 0;
|
||||||
|
let t = Math.imul(state ^ (state >>> 15), 1 | state);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a seeded 2D simplex noise function */
|
||||||
|
export function createSeededNoise(seed: number): Noise2D {
|
||||||
|
return createNoise2D(mulberry32(seed));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sample 2D noise normalized to [0, 1]
|
||||||
|
* @param noise - noise function (returns [-1, 1])
|
||||||
|
* @param x - world x coordinate
|
||||||
|
* @param y - world y coordinate
|
||||||
|
* @param scale - frequency (higher = more detail, smaller features)
|
||||||
|
*/
|
||||||
|
export function sampleNoise(
|
||||||
|
noise: Noise2D,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
scale: number,
|
||||||
|
): number {
|
||||||
|
return (noise(x * scale, y * scale) + 1) / 2;
|
||||||
|
}
|
||||||
102
src/world/tilemap.ts
Normal file
102
src/world/tilemap.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import Phaser from 'phaser';
|
||||||
|
import type { TileData, WorldData } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Phaser tilemap from generated world data
|
||||||
|
*
|
||||||
|
* 1. Generates a canvas-based tileset texture (colored squares with per-pixel variation)
|
||||||
|
* 2. Creates a Phaser Tilemap from the grid data
|
||||||
|
* 3. Sets collision for non-walkable tiles
|
||||||
|
*/
|
||||||
|
export function createWorldTilemap(
|
||||||
|
scene: Phaser.Scene,
|
||||||
|
worldData: WorldData,
|
||||||
|
): Phaser.Tilemaps.Tilemap {
|
||||||
|
const { grid, biome } = worldData;
|
||||||
|
const textureKey = `tileset-${biome.id}`;
|
||||||
|
|
||||||
|
// 1. Generate tileset texture
|
||||||
|
createTilesetTexture(scene, biome.tiles, biome.tileSize, textureKey);
|
||||||
|
|
||||||
|
// 2. Create tilemap from grid
|
||||||
|
const map = scene.make.tilemap({
|
||||||
|
data: grid,
|
||||||
|
tileWidth: biome.tileSize,
|
||||||
|
tileHeight: biome.tileSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tileset = map.addTilesetImage(textureKey, textureKey, biome.tileSize, biome.tileSize, 0, 0);
|
||||||
|
if (!tileset) {
|
||||||
|
throw new Error(`Failed to create tileset: ${textureKey}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const layer = map.createLayer(0, tileset, 0, 0);
|
||||||
|
if (!layer) {
|
||||||
|
throw new Error('Failed to create tilemap layer');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Set collision for non-walkable tiles
|
||||||
|
const nonWalkableIds = biome.tiles.filter(t => !t.walkable).map(t => t.id);
|
||||||
|
layer.setCollision(nonWalkableIds);
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a canvas tileset texture with per-pixel brightness variation
|
||||||
|
* Creates visual micro-texture so flat-colored tiles look less monotonous
|
||||||
|
*/
|
||||||
|
function createTilesetTexture(
|
||||||
|
scene: Phaser.Scene,
|
||||||
|
tiles: TileData[],
|
||||||
|
tileSize: number,
|
||||||
|
textureKey: string,
|
||||||
|
): void {
|
||||||
|
const width = tiles.length * tileSize;
|
||||||
|
const height = tileSize;
|
||||||
|
|
||||||
|
const canvasTexture = scene.textures.createCanvas(textureKey, width, height);
|
||||||
|
const ctx = canvasTexture.getContext();
|
||||||
|
const imageData = ctx.createImageData(width, height);
|
||||||
|
const pixels = imageData.data;
|
||||||
|
|
||||||
|
for (const tile of tiles) {
|
||||||
|
const [baseR, baseG, baseB] = hexToRgb(tile.color);
|
||||||
|
|
||||||
|
for (let py = 0; py < tileSize; py++) {
|
||||||
|
for (let px = 0; px < tileSize; px++) {
|
||||||
|
// Per-pixel brightness variation (±12) for texture
|
||||||
|
const hash = pixelHash(tile.id, px, py);
|
||||||
|
const variation = (hash % 25) - 12;
|
||||||
|
|
||||||
|
const idx = ((py * width) + (tile.id * tileSize + px)) * 4;
|
||||||
|
pixels[idx] = clamp(baseR + variation, 0, 255);
|
||||||
|
pixels[idx + 1] = clamp(baseG + variation, 0, 255);
|
||||||
|
pixels[idx + 2] = clamp(baseB + variation, 0, 255);
|
||||||
|
pixels[idx + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
canvasTexture.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert hex color string (#RRGGBB) to [R, G, B] */
|
||||||
|
function hexToRgb(hex: string): [number, number, number] {
|
||||||
|
const n = parseInt(hex.slice(1), 16);
|
||||||
|
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deterministic hash for per-pixel variation */
|
||||||
|
function pixelHash(tileId: number, x: number, y: number): number {
|
||||||
|
let n = tileId * 73856093 + x * 19349663 + y * 83492791;
|
||||||
|
n = ((n >> 16) ^ n) * 0x45d9f3b;
|
||||||
|
n = ((n >> 16) ^ n) * 0x45d9f3b;
|
||||||
|
return ((n >> 16) ^ n) & 0xff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clamp value to [min, max] */
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
52
src/world/types.ts
Normal file
52
src/world/types.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/** Single tile type definition */
|
||||||
|
export interface TileData {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
nameRu: string;
|
||||||
|
color: string;
|
||||||
|
walkable: boolean;
|
||||||
|
damage: number; // 0 = no damage
|
||||||
|
interactive: boolean; // true = can interact (e.g. geyser)
|
||||||
|
resource: boolean; // true = harvestable resource
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Elevation → tile mapping rule (sorted by "below" ascending) */
|
||||||
|
export interface ElevationRule {
|
||||||
|
below: number;
|
||||||
|
tileId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Biome generation parameters */
|
||||||
|
export interface BiomeGeneration {
|
||||||
|
elevationScale: number;
|
||||||
|
detailScale: number;
|
||||||
|
elevationRules: ElevationRule[];
|
||||||
|
geyserThreshold: number;
|
||||||
|
mineralThreshold: number;
|
||||||
|
geyserOnTile: number; // base tile ID where geysers can spawn
|
||||||
|
mineralOnTiles: number[]; // base tile IDs where minerals can spawn
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Complete biome definition (loaded from biomes.json) */
|
||||||
|
export interface BiomeData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
nameRu: string;
|
||||||
|
description: string;
|
||||||
|
descriptionRu: string;
|
||||||
|
tileSize: number;
|
||||||
|
mapWidth: number;
|
||||||
|
mapHeight: number;
|
||||||
|
tiles: TileData[];
|
||||||
|
generation: BiomeGeneration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 2D grid of tile IDs — row-major [y][x] */
|
||||||
|
export type TileGrid = number[][];
|
||||||
|
|
||||||
|
/** Complete generated world data */
|
||||||
|
export interface WorldData {
|
||||||
|
grid: TileGrid;
|
||||||
|
biome: BiomeData;
|
||||||
|
seed: number;
|
||||||
|
}
|
||||||
347
tests/chemistry.test.ts
Normal file
347
tests/chemistry.test.ts
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { ElementRegistry } from '../src/chemistry/elements';
|
||||||
|
import { CompoundRegistry } from '../src/chemistry/compounds';
|
||||||
|
import { ReactionEngine } from '../src/chemistry/engine';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ELEMENT REGISTRY
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe('ElementRegistry', () => {
|
||||||
|
it('should load all 20 elements', () => {
|
||||||
|
expect(ElementRegistry.count()).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should look up elements by symbol', () => {
|
||||||
|
const na = ElementRegistry.getBySymbol('Na');
|
||||||
|
expect(na).toBeDefined();
|
||||||
|
expect(na!.name).toBe('Sodium');
|
||||||
|
expect(na!.nameRu).toBe('Натрий');
|
||||||
|
expect(na!.atomicNumber).toBe(11);
|
||||||
|
expect(na!.atomicMass).toBeCloseTo(22.990, 2);
|
||||||
|
expect(na!.category).toBe('alkali-metal');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should look up elements by atomic number', () => {
|
||||||
|
const fe = ElementRegistry.getByNumber(26);
|
||||||
|
expect(fe).toBeDefined();
|
||||||
|
expect(fe!.symbol).toBe('Fe');
|
||||||
|
expect(fe!.name).toBe('Iron');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for non-existent elements', () => {
|
||||||
|
expect(ElementRegistry.getBySymbol('Xx')).toBeUndefined();
|
||||||
|
expect(ElementRegistry.getByNumber(999)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have correct data for all elements (real periodic table)', () => {
|
||||||
|
const h = ElementRegistry.getBySymbol('H')!;
|
||||||
|
expect(h.atomicNumber).toBe(1);
|
||||||
|
expect(h.atomicMass).toBeCloseTo(1.008, 2);
|
||||||
|
expect(h.state).toBe('gas');
|
||||||
|
|
||||||
|
const hg = ElementRegistry.getBySymbol('Hg')!;
|
||||||
|
expect(hg.atomicNumber).toBe(80);
|
||||||
|
expect(hg.state).toBe('liquid'); // Only metal liquid at room temp!
|
||||||
|
|
||||||
|
const he = ElementRegistry.getBySymbol('He')!;
|
||||||
|
expect(he.category).toBe('noble-gas');
|
||||||
|
expect(he.electronegativity).toBe(0);
|
||||||
|
|
||||||
|
const au = ElementRegistry.getBySymbol('Au')!;
|
||||||
|
expect(au.atomicNumber).toBe(79);
|
||||||
|
expect(au.category).toBe('transition-metal');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should identify elements correctly', () => {
|
||||||
|
expect(ElementRegistry.isElement('Na')).toBe(true);
|
||||||
|
expect(ElementRegistry.isElement('Fe')).toBe(true);
|
||||||
|
expect(ElementRegistry.isElement('NaCl')).toBe(false);
|
||||||
|
expect(ElementRegistry.isElement('H2O')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// COMPOUND REGISTRY
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe('CompoundRegistry', () => {
|
||||||
|
it('should load all compounds', () => {
|
||||||
|
expect(CompoundRegistry.count()).toBeGreaterThanOrEqual(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should look up compounds by id', () => {
|
||||||
|
const water = CompoundRegistry.getById('H2O');
|
||||||
|
expect(water).toBeDefined();
|
||||||
|
expect(water!.name).toBe('Water');
|
||||||
|
expect(water!.formula).toBe('H₂O');
|
||||||
|
expect(water!.mass).toBeCloseTo(18.015, 2);
|
||||||
|
expect(water!.state).toBe('liquid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have game effects for each compound', () => {
|
||||||
|
const salt = CompoundRegistry.getById('NaCl')!;
|
||||||
|
expect(salt.gameEffects).toContain('preservation');
|
||||||
|
|
||||||
|
const gunpowder = CompoundRegistry.getById('GUNPOWDER')!;
|
||||||
|
expect(gunpowder.gameEffects).toContain('explosive');
|
||||||
|
expect(gunpowder.properties.explosive).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly flag dangerous compounds', () => {
|
||||||
|
const hcl = CompoundRegistry.getById('HCl')!;
|
||||||
|
expect(hcl.properties.acidic).toBe(true);
|
||||||
|
expect(hcl.properties.corrosive).toBe(true);
|
||||||
|
|
||||||
|
const co = CompoundRegistry.getById('CO')!;
|
||||||
|
expect(co.properties.toxic).toBe(true);
|
||||||
|
|
||||||
|
const naoh = CompoundRegistry.getById('NaOH')!;
|
||||||
|
expect(naoh.properties.basic).toBe(true);
|
||||||
|
expect(naoh.properties.corrosive).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should identify compounds correctly', () => {
|
||||||
|
expect(CompoundRegistry.isCompound('NaCl')).toBe(true);
|
||||||
|
expect(CompoundRegistry.isCompound('H2O')).toBe(true);
|
||||||
|
expect(CompoundRegistry.isCompound('Na')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// REACTION ENGINE — SUCCESSFUL REACTIONS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe('ReactionEngine — success', () => {
|
||||||
|
it('should produce NaCl from Na + Cl', () => {
|
||||||
|
const result = ReactionEngine.react([
|
||||||
|
{ id: 'Na', count: 1 },
|
||||||
|
{ id: 'Cl', count: 1 },
|
||||||
|
]);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.products).toEqual([{ id: 'NaCl', count: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should produce H2O from 2H + O', () => {
|
||||||
|
const result = ReactionEngine.react(
|
||||||
|
[
|
||||||
|
{ id: 'H', count: 2 },
|
||||||
|
{ id: 'O', count: 1 },
|
||||||
|
],
|
||||||
|
{ minTemp: 500 },
|
||||||
|
);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.products).toEqual([{ id: 'H2O', count: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should produce CO2 from C + 2O with heat', () => {
|
||||||
|
const result = ReactionEngine.react(
|
||||||
|
[
|
||||||
|
{ id: 'C', count: 1 },
|
||||||
|
{ id: 'O', count: 2 },
|
||||||
|
],
|
||||||
|
{ minTemp: 500 },
|
||||||
|
);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.products).toEqual([{ id: 'CO2', count: 1 }]);
|
||||||
|
expect(result.reaction!.type).toBe('combustion');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should produce NaOH + H from Na + H2O (violent reaction)', () => {
|
||||||
|
const result = ReactionEngine.react([
|
||||||
|
{ id: 'Na', count: 1 },
|
||||||
|
{ id: 'H2O', count: 1 },
|
||||||
|
]);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.products).toContainEqual({ id: 'NaOH', count: 1 });
|
||||||
|
expect(result.products).toContainEqual({ id: 'H', count: 1 });
|
||||||
|
expect(result.reaction!.energyChange).toBeLessThan(-50); // Very exothermic
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should produce gunpowder from KNO3 + S + C', () => {
|
||||||
|
const result = ReactionEngine.react([
|
||||||
|
{ id: 'KNO3', count: 1 },
|
||||||
|
{ id: 'S', count: 1 },
|
||||||
|
{ id: 'C', count: 1 },
|
||||||
|
]);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.products).toEqual([{ id: 'GUNPOWDER', count: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should produce thermite: Fe2O3 + 2Al → 2Fe + Al2O3', () => {
|
||||||
|
const result = ReactionEngine.react(
|
||||||
|
[
|
||||||
|
{ id: 'Fe2O3', count: 1 },
|
||||||
|
{ id: 'Al', count: 2 },
|
||||||
|
],
|
||||||
|
{ minTemp: 1000 },
|
||||||
|
);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.products).toContainEqual({ id: 'Fe', count: 2 });
|
||||||
|
expect(result.products).toContainEqual({ id: 'Al2O3', count: 1 });
|
||||||
|
expect(result.reaction!.energyChange).toBe(-100); // Maximum exothermic
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should produce NaCl + H2O from NaOH + HCl (neutralization)', () => {
|
||||||
|
const result = ReactionEngine.react([
|
||||||
|
{ id: 'NaOH', count: 1 },
|
||||||
|
{ id: 'HCl', count: 1 },
|
||||||
|
]);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.products).toContainEqual({ id: 'NaCl', count: 1 });
|
||||||
|
expect(result.products).toContainEqual({ id: 'H2O', count: 1 });
|
||||||
|
expect(result.reaction!.type).toBe('acid-base');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should decompose H2O with energy input (electrolysis)', () => {
|
||||||
|
const result = ReactionEngine.react(
|
||||||
|
[{ id: 'H2O', count: 1 }],
|
||||||
|
{ requiresEnergy: true },
|
||||||
|
);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.products).toContainEqual({ id: 'H', count: 2 });
|
||||||
|
expect(result.products).toContainEqual({ id: 'O', count: 1 });
|
||||||
|
expect(result.reaction!.energyChange).toBeGreaterThan(0); // Endothermic
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reactant order should not matter (key is sorted)', () => {
|
||||||
|
const r1 = ReactionEngine.react([
|
||||||
|
{ id: 'Cl', count: 1 },
|
||||||
|
{ id: 'Na', count: 1 },
|
||||||
|
]);
|
||||||
|
const r2 = ReactionEngine.react([
|
||||||
|
{ id: 'Na', count: 1 },
|
||||||
|
{ id: 'Cl', count: 1 },
|
||||||
|
]);
|
||||||
|
expect(r1.success).toBe(true);
|
||||||
|
expect(r2.success).toBe(true);
|
||||||
|
expect(r1.products).toEqual(r2.products);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// REACTION ENGINE — FAILURES (educational)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe('ReactionEngine — failures', () => {
|
||||||
|
it('should reject noble gas reactions with explanation', () => {
|
||||||
|
const result = ReactionEngine.react([
|
||||||
|
{ id: 'He', count: 1 },
|
||||||
|
{ id: 'O', count: 1 },
|
||||||
|
]);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.failureReason).toContain('noble gas');
|
||||||
|
expect(result.failureReasonRu).toContain('благородный газ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject gold reactions with explanation', () => {
|
||||||
|
const result = ReactionEngine.react([
|
||||||
|
{ id: 'Au', count: 1 },
|
||||||
|
{ id: 'O', count: 1 },
|
||||||
|
]);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.failureReason).toContain('Gold');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail when heat is required but not provided', () => {
|
||||||
|
const result = ReactionEngine.react([
|
||||||
|
{ id: 'C', count: 1 },
|
||||||
|
{ id: 'O', count: 2 },
|
||||||
|
]); // No heat
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.failureReason).toContain('temperature');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail when catalyst is required but not provided', () => {
|
||||||
|
const result = ReactionEngine.react(
|
||||||
|
[
|
||||||
|
{ id: 'C', count: 1 },
|
||||||
|
{ id: 'H', count: 4 },
|
||||||
|
],
|
||||||
|
{ minTemp: 500 }, // Heat provided, but catalyst (Fe) missing
|
||||||
|
);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.failureReason).toContain('catalyst');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail when energy is required but not provided', () => {
|
||||||
|
const result = ReactionEngine.react([
|
||||||
|
{ id: 'H2O', count: 1 },
|
||||||
|
]); // Electrolysis needs energy
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.failureReason).toContain('energy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail for unknown substances', () => {
|
||||||
|
const result = ReactionEngine.react([
|
||||||
|
{ id: 'Unobtainium', count: 1 },
|
||||||
|
{ id: 'Na', count: 1 },
|
||||||
|
]);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.failureReason).toContain('Unknown substance');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail for same element with itself', () => {
|
||||||
|
const result = ReactionEngine.react([
|
||||||
|
{ id: 'Fe', count: 1 },
|
||||||
|
{ id: 'Fe', count: 1 },
|
||||||
|
]);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.failureReason).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should suggest wrong proportions when elements match but counts differ', () => {
|
||||||
|
// H + O exists? No, H:1+O:1 doesn't but H:2+O:1 does
|
||||||
|
const result = ReactionEngine.react(
|
||||||
|
[
|
||||||
|
{ id: 'H', count: 1 },
|
||||||
|
{ id: 'O', count: 1 },
|
||||||
|
],
|
||||||
|
{ minTemp: 500 },
|
||||||
|
);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.failureReason).toContain('proportions');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// REACTION ENGINE — METADATA
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe('ReactionEngine — metadata', () => {
|
||||||
|
it('should have 30+ registered reactions', () => {
|
||||||
|
expect(ReactionEngine.count()).toBeGreaterThanOrEqual(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should look up reactions by id', () => {
|
||||||
|
const thermite = ReactionEngine.getById('redox_thermite');
|
||||||
|
expect(thermite).toBeDefined();
|
||||||
|
expect(thermite!.type).toBe('redox');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every reaction should have both English and Russian descriptions', () => {
|
||||||
|
for (const r of ReactionEngine.getAll()) {
|
||||||
|
expect(r.description.length).toBeGreaterThan(10);
|
||||||
|
expect(r.descriptionRu.length).toBeGreaterThan(10);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every reaction should have valid reactants and products', () => {
|
||||||
|
for (const r of ReactionEngine.getAll()) {
|
||||||
|
expect(r.reactants.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(r.products.length).toBeGreaterThanOrEqual(1);
|
||||||
|
for (const reactant of r.reactants) {
|
||||||
|
expect(reactant.count).toBeGreaterThan(0);
|
||||||
|
const exists =
|
||||||
|
ElementRegistry.isElement(reactant.id) || CompoundRegistry.isCompound(reactant.id);
|
||||||
|
expect(exists, `Reactant "${reactant.id}" in reaction "${r.id}" not found`).toBe(true);
|
||||||
|
}
|
||||||
|
for (const product of r.products) {
|
||||||
|
expect(product.count).toBeGreaterThan(0);
|
||||||
|
const exists =
|
||||||
|
ElementRegistry.isElement(product.id) || CompoundRegistry.isCompound(product.id);
|
||||||
|
expect(exists, `Product "${product.id}" in reaction "${r.id}" not found`).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
631
tests/ecs.test.ts
Normal file
631
tests/ecs.test.ts
Normal file
@@ -0,0 +1,631 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { createWorld, addEntity, addComponent, query } from 'bitecs';
|
||||||
|
import {
|
||||||
|
Position,
|
||||||
|
Velocity,
|
||||||
|
Health,
|
||||||
|
SpriteRef,
|
||||||
|
ChemicalComposition,
|
||||||
|
} from '../src/ecs/components';
|
||||||
|
import {
|
||||||
|
createGameWorld,
|
||||||
|
updateTime,
|
||||||
|
type World,
|
||||||
|
} from '../src/ecs/world';
|
||||||
|
import {
|
||||||
|
movementSystem,
|
||||||
|
bounceSystem,
|
||||||
|
} from '../src/ecs/systems/movement';
|
||||||
|
import {
|
||||||
|
healthSystem,
|
||||||
|
applyDamage,
|
||||||
|
applyHealing,
|
||||||
|
} from '../src/ecs/systems/health';
|
||||||
|
import {
|
||||||
|
createGameEntity,
|
||||||
|
removeGameEntity,
|
||||||
|
} from '../src/ecs/factory';
|
||||||
|
|
||||||
|
// ─── World ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('World', () => {
|
||||||
|
it('creates a game world with zeroed time', () => {
|
||||||
|
const gw = createGameWorld();
|
||||||
|
expect(gw.world).toBeDefined();
|
||||||
|
expect(gw.time.delta).toBe(0);
|
||||||
|
expect(gw.time.elapsed).toBe(0);
|
||||||
|
expect(gw.time.tick).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates time tracking correctly', () => {
|
||||||
|
const gw = createGameWorld();
|
||||||
|
|
||||||
|
updateTime(gw, 16.67);
|
||||||
|
expect(gw.time.delta).toBeCloseTo(16.67);
|
||||||
|
expect(gw.time.elapsed).toBeCloseTo(16.67);
|
||||||
|
expect(gw.time.tick).toBe(1);
|
||||||
|
|
||||||
|
updateTime(gw, 16.67);
|
||||||
|
expect(gw.time.delta).toBeCloseTo(16.67);
|
||||||
|
expect(gw.time.elapsed).toBeCloseTo(33.34);
|
||||||
|
expect(gw.time.tick).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks varying delta times', () => {
|
||||||
|
const gw = createGameWorld();
|
||||||
|
|
||||||
|
updateTime(gw, 10);
|
||||||
|
updateTime(gw, 20);
|
||||||
|
updateTime(gw, 30);
|
||||||
|
|
||||||
|
expect(gw.time.delta).toBe(30);
|
||||||
|
expect(gw.time.elapsed).toBe(60);
|
||||||
|
expect(gw.time.tick).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Components ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Components', () => {
|
||||||
|
let world: World;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = createWorld();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores Position data for entities', () => {
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Position);
|
||||||
|
Position.x[eid] = 100;
|
||||||
|
Position.y[eid] = 200;
|
||||||
|
|
||||||
|
expect(Position.x[eid]).toBe(100);
|
||||||
|
expect(Position.y[eid]).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores Velocity data for entities', () => {
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Velocity);
|
||||||
|
Velocity.vx[eid] = 50;
|
||||||
|
Velocity.vy[eid] = -30;
|
||||||
|
|
||||||
|
expect(Velocity.vx[eid]).toBe(50);
|
||||||
|
expect(Velocity.vy[eid]).toBe(-30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores Health data for entities', () => {
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Health);
|
||||||
|
Health.current[eid] = 80;
|
||||||
|
Health.max[eid] = 100;
|
||||||
|
|
||||||
|
expect(Health.current[eid]).toBe(80);
|
||||||
|
expect(Health.max[eid]).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores SpriteRef data for entities', () => {
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, SpriteRef);
|
||||||
|
SpriteRef.color[eid] = 0x00ff88;
|
||||||
|
SpriteRef.radius[eid] = 12;
|
||||||
|
|
||||||
|
expect(SpriteRef.color[eid]).toBe(0x00ff88);
|
||||||
|
expect(SpriteRef.radius[eid]).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores ChemicalComposition for entities', () => {
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, ChemicalComposition);
|
||||||
|
ChemicalComposition.primaryElement[eid] = 11; // Na
|
||||||
|
|
||||||
|
expect(ChemicalComposition.primaryElement[eid]).toBe(11);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queries entities by single component', () => {
|
||||||
|
const e1 = addEntity(world);
|
||||||
|
const e2 = addEntity(world);
|
||||||
|
addComponent(world, e1, Position);
|
||||||
|
addComponent(world, e2, Position);
|
||||||
|
|
||||||
|
const entities = [...query(world, [Position])];
|
||||||
|
expect(entities).toContain(e1);
|
||||||
|
expect(entities).toContain(e2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queries entities by multiple components', () => {
|
||||||
|
const moving = addEntity(world);
|
||||||
|
const stationary = addEntity(world);
|
||||||
|
|
||||||
|
addComponent(world, moving, Position);
|
||||||
|
addComponent(world, moving, Velocity);
|
||||||
|
addComponent(world, stationary, Position);
|
||||||
|
|
||||||
|
const movingEntities = [...query(world, [Position, Velocity])];
|
||||||
|
expect(movingEntities).toContain(moving);
|
||||||
|
expect(movingEntities).not.toContain(stationary);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Movement System ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Movement System', () => {
|
||||||
|
let world: World;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = createWorld();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates position by velocity * delta', () => {
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Position);
|
||||||
|
addComponent(world, eid, Velocity);
|
||||||
|
Position.x[eid] = 100;
|
||||||
|
Position.y[eid] = 200;
|
||||||
|
Velocity.vx[eid] = 60;
|
||||||
|
Velocity.vy[eid] = -30;
|
||||||
|
|
||||||
|
movementSystem(world, 1000); // 1 second
|
||||||
|
|
||||||
|
expect(Position.x[eid]).toBeCloseTo(160);
|
||||||
|
expect(Position.y[eid]).toBeCloseTo(170);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles fractional delta (16.67ms ≈ 60fps)', () => {
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Position);
|
||||||
|
addComponent(world, eid, Velocity);
|
||||||
|
Position.x[eid] = 0;
|
||||||
|
Position.y[eid] = 0;
|
||||||
|
Velocity.vx[eid] = 100;
|
||||||
|
Velocity.vy[eid] = 100;
|
||||||
|
|
||||||
|
movementSystem(world, 16.67);
|
||||||
|
|
||||||
|
expect(Position.x[eid]).toBeCloseTo(1.667, 1);
|
||||||
|
expect(Position.y[eid]).toBeCloseTo(1.667, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not move entities without Velocity', () => {
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Position);
|
||||||
|
Position.x[eid] = 100;
|
||||||
|
Position.y[eid] = 200;
|
||||||
|
|
||||||
|
movementSystem(world, 1000);
|
||||||
|
|
||||||
|
expect(Position.x[eid]).toBe(100);
|
||||||
|
expect(Position.y[eid]).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles negative velocities', () => {
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Position);
|
||||||
|
addComponent(world, eid, Velocity);
|
||||||
|
Position.x[eid] = 500;
|
||||||
|
Position.y[eid] = 500;
|
||||||
|
Velocity.vx[eid] = -100;
|
||||||
|
Velocity.vy[eid] = -200;
|
||||||
|
|
||||||
|
movementSystem(world, 1000);
|
||||||
|
|
||||||
|
expect(Position.x[eid]).toBeCloseTo(400);
|
||||||
|
expect(Position.y[eid]).toBeCloseTo(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles zero delta', () => {
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Position);
|
||||||
|
addComponent(world, eid, Velocity);
|
||||||
|
Position.x[eid] = 100;
|
||||||
|
Position.y[eid] = 200;
|
||||||
|
Velocity.vx[eid] = 999;
|
||||||
|
Velocity.vy[eid] = 999;
|
||||||
|
|
||||||
|
movementSystem(world, 0);
|
||||||
|
|
||||||
|
expect(Position.x[eid]).toBe(100);
|
||||||
|
expect(Position.y[eid]).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves multiple entities independently', () => {
|
||||||
|
const e1 = addEntity(world);
|
||||||
|
const e2 = addEntity(world);
|
||||||
|
|
||||||
|
addComponent(world, e1, Position);
|
||||||
|
addComponent(world, e1, Velocity);
|
||||||
|
addComponent(world, e2, Position);
|
||||||
|
addComponent(world, e2, Velocity);
|
||||||
|
|
||||||
|
Position.x[e1] = 0;
|
||||||
|
Position.y[e1] = 0;
|
||||||
|
Velocity.vx[e1] = 100;
|
||||||
|
Velocity.vy[e1] = 0;
|
||||||
|
|
||||||
|
Position.x[e2] = 100;
|
||||||
|
Position.y[e2] = 100;
|
||||||
|
Velocity.vx[e2] = 0;
|
||||||
|
Velocity.vy[e2] = -50;
|
||||||
|
|
||||||
|
movementSystem(world, 1000);
|
||||||
|
|
||||||
|
expect(Position.x[e1]).toBeCloseTo(100);
|
||||||
|
expect(Position.y[e1]).toBeCloseTo(0);
|
||||||
|
expect(Position.x[e2]).toBeCloseTo(100);
|
||||||
|
expect(Position.y[e2]).toBeCloseTo(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Bounce System ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Bounce System', () => {
|
||||||
|
let world: World;
|
||||||
|
const WIDTH = 1280;
|
||||||
|
const HEIGHT = 720;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = createWorld();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bounces at left boundary (x < 0)', () => {
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Position);
|
||||||
|
addComponent(world, eid, Velocity);
|
||||||
|
Position.x[eid] = -5;
|
||||||
|
Position.y[eid] = 100;
|
||||||
|
Velocity.vx[eid] = -100;
|
||||||
|
Velocity.vy[eid] = 0;
|
||||||
|
|
||||||
|
bounceSystem(world, WIDTH, HEIGHT);
|
||||||
|
|
||||||
|
expect(Position.x[eid]).toBe(0);
|
||||||
|
expect(Velocity.vx[eid]).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bounces at right boundary (x > width)', () => {
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Position);
|
||||||
|
addComponent(world, eid, Velocity);
|
||||||
|
Position.x[eid] = WIDTH + 5;
|
||||||
|
Position.y[eid] = 100;
|
||||||
|
Velocity.vx[eid] = 100;
|
||||||
|
Velocity.vy[eid] = 0;
|
||||||
|
|
||||||
|
bounceSystem(world, WIDTH, HEIGHT);
|
||||||
|
|
||||||
|
expect(Position.x[eid]).toBe(WIDTH);
|
||||||
|
expect(Velocity.vx[eid]).toBe(-100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bounces at top boundary (y < 0)', () => {
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Position);
|
||||||
|
addComponent(world, eid, Velocity);
|
||||||
|
Position.x[eid] = 100;
|
||||||
|
Position.y[eid] = -10;
|
||||||
|
Velocity.vx[eid] = 0;
|
||||||
|
Velocity.vy[eid] = -50;
|
||||||
|
|
||||||
|
bounceSystem(world, WIDTH, HEIGHT);
|
||||||
|
|
||||||
|
expect(Position.y[eid]).toBe(0);
|
||||||
|
expect(Velocity.vy[eid]).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bounces at bottom boundary (y > height)', () => {
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Position);
|
||||||
|
addComponent(world, eid, Velocity);
|
||||||
|
Position.x[eid] = 100;
|
||||||
|
Position.y[eid] = HEIGHT + 10;
|
||||||
|
Velocity.vx[eid] = 0;
|
||||||
|
Velocity.vy[eid] = 200;
|
||||||
|
|
||||||
|
bounceSystem(world, WIDTH, HEIGHT);
|
||||||
|
|
||||||
|
expect(Position.y[eid]).toBe(HEIGHT);
|
||||||
|
expect(Velocity.vy[eid]).toBe(-200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not affect entities within bounds', () => {
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Position);
|
||||||
|
addComponent(world, eid, Velocity);
|
||||||
|
Position.x[eid] = 640;
|
||||||
|
Position.y[eid] = 360;
|
||||||
|
Velocity.vx[eid] = 100;
|
||||||
|
Velocity.vy[eid] = -50;
|
||||||
|
|
||||||
|
bounceSystem(world, WIDTH, HEIGHT);
|
||||||
|
|
||||||
|
expect(Position.x[eid]).toBe(640);
|
||||||
|
expect(Position.y[eid]).toBe(360);
|
||||||
|
expect(Velocity.vx[eid]).toBe(100);
|
||||||
|
expect(Velocity.vy[eid]).toBe(-50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles corner bounce (both axes out of bounds)', () => {
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Position);
|
||||||
|
addComponent(world, eid, Velocity);
|
||||||
|
Position.x[eid] = -5;
|
||||||
|
Position.y[eid] = -10;
|
||||||
|
Velocity.vx[eid] = -100;
|
||||||
|
Velocity.vy[eid] = -200;
|
||||||
|
|
||||||
|
bounceSystem(world, WIDTH, HEIGHT);
|
||||||
|
|
||||||
|
expect(Position.x[eid]).toBe(0);
|
||||||
|
expect(Position.y[eid]).toBe(0);
|
||||||
|
expect(Velocity.vx[eid]).toBe(100);
|
||||||
|
expect(Velocity.vy[eid]).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Health System ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Health System', () => {
|
||||||
|
let world: World;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = createWorld();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects entities with health = 0', () => {
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Health);
|
||||||
|
Health.current[eid] = 0;
|
||||||
|
Health.max[eid] = 100;
|
||||||
|
|
||||||
|
const dead = healthSystem(world);
|
||||||
|
expect(dead).toContain(eid);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects entities with negative health', () => {
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Health);
|
||||||
|
Health.current[eid] = -50;
|
||||||
|
Health.max[eid] = 100;
|
||||||
|
|
||||||
|
const dead = healthSystem(world);
|
||||||
|
expect(dead).toContain(eid);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not flag healthy entities', () => {
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Health);
|
||||||
|
Health.current[eid] = 50;
|
||||||
|
Health.max[eid] = 100;
|
||||||
|
|
||||||
|
const dead = healthSystem(world);
|
||||||
|
expect(dead).not.toContain(eid);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when all entities are healthy', () => {
|
||||||
|
const e1 = addEntity(world);
|
||||||
|
const e2 = addEntity(world);
|
||||||
|
addComponent(world, e1, Health);
|
||||||
|
addComponent(world, e2, Health);
|
||||||
|
Health.current[e1] = 100;
|
||||||
|
Health.max[e1] = 100;
|
||||||
|
Health.current[e2] = 1;
|
||||||
|
Health.max[e2] = 100;
|
||||||
|
|
||||||
|
const dead = healthSystem(world);
|
||||||
|
expect(dead).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns multiple dead entities', () => {
|
||||||
|
const e1 = addEntity(world);
|
||||||
|
const e2 = addEntity(world);
|
||||||
|
const e3 = addEntity(world);
|
||||||
|
addComponent(world, e1, Health);
|
||||||
|
addComponent(world, e2, Health);
|
||||||
|
addComponent(world, e3, Health);
|
||||||
|
Health.current[e1] = 0;
|
||||||
|
Health.max[e1] = 100;
|
||||||
|
Health.current[e2] = -10;
|
||||||
|
Health.max[e2] = 50;
|
||||||
|
Health.current[e3] = 50;
|
||||||
|
Health.max[e3] = 100;
|
||||||
|
|
||||||
|
const dead = healthSystem(world);
|
||||||
|
expect(dead).toContain(e1);
|
||||||
|
expect(dead).toContain(e2);
|
||||||
|
expect(dead).not.toContain(e3);
|
||||||
|
expect(dead).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Damage & Healing ───────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Damage and Healing', () => {
|
||||||
|
let world: World;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = createWorld();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies damage correctly', () => {
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Health);
|
||||||
|
Health.current[eid] = 100;
|
||||||
|
Health.max[eid] = 100;
|
||||||
|
|
||||||
|
applyDamage(eid, 30);
|
||||||
|
expect(Health.current[eid]).toBe(70);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows overkill (negative health)', () => {
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Health);
|
||||||
|
Health.current[eid] = 10;
|
||||||
|
Health.max[eid] = 100;
|
||||||
|
|
||||||
|
applyDamage(eid, 50);
|
||||||
|
expect(Health.current[eid]).toBe(-40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies healing correctly', () => {
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Health);
|
||||||
|
Health.current[eid] = 50;
|
||||||
|
Health.max[eid] = 100;
|
||||||
|
|
||||||
|
applyHealing(eid, 30);
|
||||||
|
expect(Health.current[eid]).toBe(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps healing at max health', () => {
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Health);
|
||||||
|
Health.current[eid] = 90;
|
||||||
|
Health.max[eid] = 100;
|
||||||
|
|
||||||
|
applyHealing(eid, 50);
|
||||||
|
expect(Health.current[eid]).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('full damage → heal → kill lifecycle', () => {
|
||||||
|
const eid = addEntity(world);
|
||||||
|
addComponent(world, eid, Health);
|
||||||
|
Health.current[eid] = 100;
|
||||||
|
Health.max[eid] = 100;
|
||||||
|
|
||||||
|
applyDamage(eid, 60);
|
||||||
|
expect(Health.current[eid]).toBe(40);
|
||||||
|
|
||||||
|
applyHealing(eid, 20);
|
||||||
|
expect(Health.current[eid]).toBe(60);
|
||||||
|
|
||||||
|
applyDamage(eid, 70);
|
||||||
|
expect(Health.current[eid]).toBe(-10);
|
||||||
|
|
||||||
|
const dead = healthSystem(world);
|
||||||
|
expect(dead).toContain(eid);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Entity Factory ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Entity Factory', () => {
|
||||||
|
let world: World;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = createWorld();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates entity with position only', () => {
|
||||||
|
const eid = createGameEntity(world, {
|
||||||
|
position: { x: 100, y: 200 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(typeof eid).toBe('number');
|
||||||
|
const entities = [...query(world, [Position])];
|
||||||
|
expect(entities).toContain(eid);
|
||||||
|
expect(Position.x[eid]).toBe(100);
|
||||||
|
expect(Position.y[eid]).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates entity with all components', () => {
|
||||||
|
const eid = createGameEntity(world, {
|
||||||
|
position: { x: 10, y: 20 },
|
||||||
|
velocity: { vx: 30, vy: 40 },
|
||||||
|
health: { current: 80, max: 100 },
|
||||||
|
sprite: { color: 0xff0000, radius: 8 },
|
||||||
|
chemicalElement: 26, // Fe
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(Position.x[eid]).toBe(10);
|
||||||
|
expect(Position.y[eid]).toBe(20);
|
||||||
|
expect(Velocity.vx[eid]).toBe(30);
|
||||||
|
expect(Velocity.vy[eid]).toBe(40);
|
||||||
|
expect(Health.current[eid]).toBe(80);
|
||||||
|
expect(Health.max[eid]).toBe(100);
|
||||||
|
expect(SpriteRef.color[eid]).toBe(0xff0000);
|
||||||
|
expect(SpriteRef.radius[eid]).toBe(8);
|
||||||
|
expect(ChemicalComposition.primaryElement[eid]).toBe(26);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates multiple independent entities', () => {
|
||||||
|
const e1 = createGameEntity(world, {
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
});
|
||||||
|
const e2 = createGameEntity(world, {
|
||||||
|
position: { x: 100, y: 100 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(e1).not.toBe(e2);
|
||||||
|
expect(Position.x[e1]).toBe(0);
|
||||||
|
expect(Position.x[e2]).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes entity from world', () => {
|
||||||
|
const eid = createGameEntity(world, {
|
||||||
|
position: { x: 100, y: 200 },
|
||||||
|
health: { current: 50, max: 100 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect([...query(world, [Position])]).toContain(eid);
|
||||||
|
|
||||||
|
removeGameEntity(world, eid);
|
||||||
|
|
||||||
|
expect([...query(world, [Position])]).not.toContain(eid);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates entity without any components', () => {
|
||||||
|
const eid = createGameEntity(world, {});
|
||||||
|
expect(typeof eid).toBe('number');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Integration ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Integration', () => {
|
||||||
|
it('full lifecycle: create → move → damage → die → remove', () => {
|
||||||
|
const { world } = createGameWorld();
|
||||||
|
|
||||||
|
const eid = createGameEntity(world, {
|
||||||
|
position: { x: 100, y: 100 },
|
||||||
|
velocity: { vx: 200, vy: 0 },
|
||||||
|
health: { current: 50, max: 100 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Move for 1 second
|
||||||
|
movementSystem(world, 1000);
|
||||||
|
expect(Position.x[eid]).toBeCloseTo(300);
|
||||||
|
|
||||||
|
// Lethal damage
|
||||||
|
applyDamage(eid, 60);
|
||||||
|
expect(Health.current[eid]).toBe(-10);
|
||||||
|
|
||||||
|
// Health system detects death
|
||||||
|
const dead = healthSystem(world);
|
||||||
|
expect(dead).toContain(eid);
|
||||||
|
|
||||||
|
// Remove dead entity
|
||||||
|
removeGameEntity(world, eid);
|
||||||
|
expect([...query(world, [Health])]).not.toContain(eid);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('movement + bounce in sequence', () => {
|
||||||
|
const { world } = createGameWorld();
|
||||||
|
|
||||||
|
const eid = createGameEntity(world, {
|
||||||
|
position: { x: 1270, y: 360 },
|
||||||
|
velocity: { vx: 100, vy: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Move past right boundary (1270 + 100*0.5 = 1320 > 1280)
|
||||||
|
movementSystem(world, 500);
|
||||||
|
expect(Position.x[eid]).toBeCloseTo(1320);
|
||||||
|
|
||||||
|
// Bounce should clamp and reverse
|
||||||
|
bounceSystem(world, 1280, 720);
|
||||||
|
expect(Position.x[eid]).toBe(1280);
|
||||||
|
expect(Velocity.vx[eid]).toBe(-100);
|
||||||
|
|
||||||
|
// Next frame: move away from boundary
|
||||||
|
movementSystem(world, 1000);
|
||||||
|
expect(Position.x[eid]).toBeCloseTo(1180);
|
||||||
|
});
|
||||||
|
});
|
||||||
183
tests/world.test.ts
Normal file
183
tests/world.test.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import biomeDataArray from '../src/data/biomes.json';
|
||||||
|
import { createSeededNoise, sampleNoise } from '../src/world/noise';
|
||||||
|
import { generateWorld } from '../src/world/generator';
|
||||||
|
import type { BiomeData } from '../src/world/types';
|
||||||
|
|
||||||
|
// Load the first biome — structural compatibility with BiomeData
|
||||||
|
const biome = biomeDataArray[0] as BiomeData;
|
||||||
|
|
||||||
|
// ─── Noise ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Seeded Noise', () => {
|
||||||
|
it('is deterministic with same seed', () => {
|
||||||
|
const n1 = createSeededNoise(42);
|
||||||
|
const n2 = createSeededNoise(42);
|
||||||
|
expect(n1(0.5, 0.5)).toBe(n2(0.5, 0.5));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces different results with different seeds', () => {
|
||||||
|
const n1 = createSeededNoise(42);
|
||||||
|
const n2 = createSeededNoise(99);
|
||||||
|
expect(n1(0.5, 0.5)).not.toBe(n2(0.5, 0.5));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes to [0, 1] via sampleNoise', () => {
|
||||||
|
const noise = createSeededNoise(42);
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
const val = sampleNoise(noise, i * 0.3, i * 0.7, 0.1);
|
||||||
|
expect(val).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(val).toBeLessThanOrEqual(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('varies across coordinates', () => {
|
||||||
|
const noise = createSeededNoise(42);
|
||||||
|
const values = new Set<number>();
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
values.add(sampleNoise(noise, i, 0, 0.1));
|
||||||
|
}
|
||||||
|
// Should have significant variety (not all same value)
|
||||||
|
expect(values.size).toBeGreaterThan(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Biome Data ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Biome Data', () => {
|
||||||
|
it('has valid structure', () => {
|
||||||
|
expect(biome.id).toBe('catalytic-wastes');
|
||||||
|
expect(biome.tileSize).toBe(32);
|
||||||
|
expect(biome.mapWidth).toBe(80);
|
||||||
|
expect(biome.mapHeight).toBe(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has 8 tile types', () => {
|
||||||
|
expect(biome.tiles).toHaveLength(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tile IDs are sequential starting from 0', () => {
|
||||||
|
biome.tiles.forEach((tile, index) => {
|
||||||
|
expect(tile.id).toBe(index);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has both walkable and non-walkable tiles', () => {
|
||||||
|
const walkable = biome.tiles.filter(t => t.walkable);
|
||||||
|
const blocked = biome.tiles.filter(t => !t.walkable);
|
||||||
|
expect(walkable.length).toBeGreaterThan(0);
|
||||||
|
expect(blocked.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('elevation rules cover full [0, 1] range', () => {
|
||||||
|
const rules = biome.generation.elevationRules;
|
||||||
|
expect(rules.length).toBeGreaterThan(0);
|
||||||
|
// Last rule should cover up to 1.0
|
||||||
|
expect(rules[rules.length - 1].below).toBe(1);
|
||||||
|
// Rules should be sorted ascending
|
||||||
|
for (let i = 1; i < rules.length; i++) {
|
||||||
|
expect(rules[i].below).toBeGreaterThan(rules[i - 1].below);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all elevation rule tileIds reference valid tiles', () => {
|
||||||
|
const validIds = new Set(biome.tiles.map(t => t.id));
|
||||||
|
for (const rule of biome.generation.elevationRules) {
|
||||||
|
expect(validIds.has(rule.tileId)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── World Generation ───────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('World Generation', () => {
|
||||||
|
it('generates grid with correct dimensions', () => {
|
||||||
|
const world = generateWorld(biome, 42);
|
||||||
|
expect(world.grid).toHaveLength(biome.mapHeight);
|
||||||
|
for (const row of world.grid) {
|
||||||
|
expect(row).toHaveLength(biome.mapWidth);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all tile IDs in grid are valid', () => {
|
||||||
|
const world = generateWorld(biome, 42);
|
||||||
|
const validIds = new Set(biome.tiles.map(t => t.id));
|
||||||
|
for (const row of world.grid) {
|
||||||
|
for (const tileId of row) {
|
||||||
|
expect(validIds.has(tileId)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is deterministic — same seed same map', () => {
|
||||||
|
const w1 = generateWorld(biome, 42);
|
||||||
|
const w2 = generateWorld(biome, 42);
|
||||||
|
expect(w1.grid).toEqual(w2.grid);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('different seeds produce different maps', () => {
|
||||||
|
const w1 = generateWorld(biome, 42);
|
||||||
|
const w2 = generateWorld(biome, 99);
|
||||||
|
expect(w1.grid).not.toEqual(w2.grid);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores seed and biome reference', () => {
|
||||||
|
const world = generateWorld(biome, 12345);
|
||||||
|
expect(world.seed).toBe(12345);
|
||||||
|
expect(world.biome).toBe(biome);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has diverse tile distribution (no single tile > 60%)', () => {
|
||||||
|
const world = generateWorld(biome, 42);
|
||||||
|
const counts = new Map<number, number>();
|
||||||
|
for (const row of world.grid) {
|
||||||
|
for (const tileId of row) {
|
||||||
|
counts.set(tileId, (counts.get(tileId) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const total = biome.mapWidth * biome.mapHeight;
|
||||||
|
// At least 4 different tile types present
|
||||||
|
expect(counts.size).toBeGreaterThanOrEqual(4);
|
||||||
|
// No single type dominates
|
||||||
|
for (const count of counts.values()) {
|
||||||
|
expect(count / total).toBeLessThan(0.6);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates acid pools (low elevation)', () => {
|
||||||
|
const world = generateWorld(biome, 42);
|
||||||
|
const acidId = biome.tiles.find(t => t.name === 'acid-pool')?.id;
|
||||||
|
const hasAcid = world.grid.some(row => row.includes(acidId!));
|
||||||
|
expect(hasAcid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates crystal formations (high elevation)', () => {
|
||||||
|
const world = generateWorld(biome, 42);
|
||||||
|
const crystalId = biome.tiles.find(t => t.name === 'crystal')?.id;
|
||||||
|
const hasCrystals = world.grid.some(row => row.includes(crystalId!));
|
||||||
|
expect(hasCrystals).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates mineral veins (overlay on ground)', () => {
|
||||||
|
const world = generateWorld(biome, 42);
|
||||||
|
const mineralId = biome.tiles.find(t => t.name === 'mineral-vein')?.id;
|
||||||
|
const hasMinerals = world.grid.some(row => row.includes(mineralId!));
|
||||||
|
expect(hasMinerals).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates geysers (overlay near acid)', () => {
|
||||||
|
const world = generateWorld(biome, 42);
|
||||||
|
const geyserId = biome.tiles.find(t => t.name === 'geyser')?.id;
|
||||||
|
const hasGeysers = world.grid.some(row => row.includes(geyserId!));
|
||||||
|
expect(hasGeysers).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces unique map every seed (sample 5 seeds)', () => {
|
||||||
|
const grids = [1, 2, 3, 4, 5].map(s => generateWorld(biome, s).grid);
|
||||||
|
for (let i = 0; i < grids.length; i++) {
|
||||||
|
for (let j = i + 1; j < grids.length; j++) {
|
||||||
|
expect(grids[i]).not.toEqual(grids[j]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user