phase 7: Mycelium — persistent knowledge network connecting runs
- Mycelium graph: nodes/edges/strength, deposit discoveries, weighted extraction - Fungal nodes: ECS entities on world map with bioluminescent glow animation - Knowledge system: deposit at nodes (+ auto on death), memory flash retrieval - Mycosis: visual tint overlay on prolonged node contact, reveal threshold - Spore shop: 5 Cradle bonuses (health, elements, knowledge boost) - MetaState extended with MyceliumGraphData, IndexedDB persistence updated - GameScene: node spawning, glow rendering, E-key interaction, mycosis overlay - CradleScene: spore shop UI, Mycelium stats, purchased effects forwarding - 36 new tests (385 total) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -172,7 +172,7 @@
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Mycelium
|
||||
## Phase 7: Mycelium ✅
|
||||
|
||||
**Цель:** Подземная сеть, связывающая раны
|
||||
**Зависимости:** Phase 6
|
||||
|
||||
21
PROGRESS.md
21
PROGRESS.md
@@ -1,7 +1,7 @@
|
||||
# Synthesis — Development Progress
|
||||
|
||||
> **Last updated:** 2026-02-12
|
||||
> **Current phase:** Phase 6 ✅ → Ready for Phase 7
|
||||
> **Current phase:** Phase 7 ✅ → Ready for Phase 8
|
||||
|
||||
---
|
||||
|
||||
@@ -86,15 +86,29 @@
|
||||
- [x] Texture cleanup for multi-run support (tilemap + minimap textures removed before recreation)
|
||||
- [x] Unit tests — 42 run-cycle + 14 escalation = 56 tests (349 total)
|
||||
|
||||
### Phase 7: Mycelium ✅
|
||||
- [x] 7.1 Mycelium Graph — persistent knowledge network (nodes, edges, deposit/strengthen/query) (`src/mycelium/graph.ts`)
|
||||
- [x] 7.2 Fungal Nodes — ECS entities on world map, glowing bioluminescent spots, pulsing animation (`src/mycelium/nodes.ts`)
|
||||
- [x] 7.3 Knowledge Recording — deposit run discoveries into Mycelium (auto on death + manual at nodes) (`src/mycelium/knowledge.ts`)
|
||||
- [x] 7.4 Knowledge Extraction — memory flashes from past runs, weighted by node strength, Russian text templates (`src/mycelium/knowledge.ts`)
|
||||
- [x] 7.5 Mycosis — visual distortion (tint overlay) on prolonged fungal node contact, reveals hidden info at threshold (`src/mycelium/mycosis.ts`)
|
||||
- [x] 7.6 Spore Shop — Cradle integration: spend spores for starting bonuses (extra health, elements, knowledge boost) (`src/mycelium/shop.ts`)
|
||||
- [x] ECS component: FungalNode (nodeIndex, glowPhase, interactRange) (`src/ecs/components.ts`)
|
||||
- [x] Data: mycelium config, spore bonuses, memory templates (`src/data/mycelium.json`)
|
||||
- [x] MetaState extended with MyceliumGraphData, IndexedDB persistence updated
|
||||
- [x] GameScene integration: node spawning, glow rendering, E-key interaction, mycosis overlay, memory flash display
|
||||
- [x] CradleScene integration: spore shop UI, Mycelium stats display, purchased effects passed to GameScene
|
||||
- [x] Unit tests — 36 passing (`tests/mycelium.test.ts`) (385 total)
|
||||
|
||||
---
|
||||
|
||||
## In Progress
|
||||
|
||||
_None — ready to begin Phase 7_
|
||||
_None — ready to begin Phase 8_
|
||||
|
||||
---
|
||||
|
||||
## Up Next: Phase 7 — Mycelium
|
||||
## Up Next: Phase 8 — First Archont: Ouroboros
|
||||
|
||||
_(See IMPLEMENTATION-PLAN.md for details)_
|
||||
|
||||
@@ -117,3 +131,4 @@ None
|
||||
| 5 | 2026-02-12 | Phase 4 | Player systems: WASD movement + tile collision, weight-based inventory, resource collection, crafting via chemistry engine, projectile throw, 4 quick slots, UIScene HUD overlay (health bar, slots, inventory), 126 new tests (222 total) |
|
||||
| 6 | 2026-02-12 | Phase 5 | Creatures & Ecology: 3 species (Crystallid/Acidophile/Reagent), FSM AI (idle/wander/feed/flee/attack), metabolism (energy drain/feeding/starvation), life cycle (egg→youth→mature→aging→death), population dynamics, projectile-creature collision with armor, creature→player melee, ecosystem simulation test, 72 new tests (293 total) |
|
||||
| 7 | 2026-02-12 | Phase 6 | Run Cycle: full roguelike loop (Cradle→Game→Death→Fractal→Cradle), school selection (Alchemist), meta-progression (Codex/spores/IndexedDB), run phases with auto-advance, escalation effects (creature aggression/env damage), Chemical Plague crisis with neutralization, death animation (real body composition), WebGL fractal shader, 56 new tests (349 total) |
|
||||
| 8 | 2026-02-12 | Phase 7 | Mycelium: persistent knowledge graph (nodes/edges/strength), fungal node ECS entities with glow animation, knowledge deposit (auto on death + manual at nodes), memory flash extraction (weighted by strength, Russian templates), mycosis visual effect (tint overlay + reveal threshold), spore shop in Cradle (5 bonuses: health/elements/knowledge), MetaState+IndexedDB persistence updated, GameScene+CradleScene integration, 36 new tests (385 total) |
|
||||
|
||||
75
src/data/mycelium.json
Normal file
75
src/data/mycelium.json
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"sporeBonuses": [
|
||||
{
|
||||
"id": "vitality",
|
||||
"name": "Vital Spores",
|
||||
"nameRu": "Споры Живучести",
|
||||
"description": "Mycelium reinforces your body. +25 max health.",
|
||||
"descriptionRu": "Мицелий укрепляет тело. +25 к максимальному здоровью.",
|
||||
"cost": 20,
|
||||
"effect": { "type": "extra_health", "amount": 25 },
|
||||
"repeatable": true
|
||||
},
|
||||
{
|
||||
"id": "carbon_gift",
|
||||
"name": "Carbon Cache",
|
||||
"nameRu": "Запас Углерода",
|
||||
"description": "A fungal node releases stored carbon. +3 C.",
|
||||
"descriptionRu": "Грибной узел отдаёт накопленный углерод. +3 C.",
|
||||
"cost": 10,
|
||||
"effect": { "type": "extra_element", "symbol": "C", "quantity": 3 },
|
||||
"repeatable": true
|
||||
},
|
||||
{
|
||||
"id": "sulfur_gift",
|
||||
"name": "Sulfur Reserve",
|
||||
"nameRu": "Запас Серы",
|
||||
"description": "Deep mycelium threads carry sulfur to the surface. +3 S.",
|
||||
"descriptionRu": "Глубинные нити мицелия доставляют серу. +3 S.",
|
||||
"cost": 10,
|
||||
"effect": { "type": "extra_element", "symbol": "S", "quantity": 3 },
|
||||
"repeatable": true
|
||||
},
|
||||
{
|
||||
"id": "iron_gift",
|
||||
"name": "Iron Vein",
|
||||
"nameRu": "Железная Жила",
|
||||
"description": "The network has absorbed iron from old ruins. +2 Fe.",
|
||||
"descriptionRu": "Сеть впитала железо из древних руин. +2 Fe.",
|
||||
"cost": 15,
|
||||
"effect": { "type": "extra_element", "symbol": "Fe", "quantity": 2 },
|
||||
"repeatable": true
|
||||
},
|
||||
{
|
||||
"id": "memory_boost",
|
||||
"name": "Fungal Attunement",
|
||||
"nameRu": "Грибная Настройка",
|
||||
"description": "Clearer memories from the network. Memory flashes are more detailed.",
|
||||
"descriptionRu": "Яснее воспоминания сети. Вспышки памяти более детальны.",
|
||||
"cost": 30,
|
||||
"effect": { "type": "knowledge_boost", "multiplier": 1.5 },
|
||||
"repeatable": false
|
||||
}
|
||||
],
|
||||
"memoryTemplates": {
|
||||
"element_hint": [
|
||||
{ "text": "A faint echo... {element} reacts with something nearby...", "textRu": "Слабое эхо... {element} реагирует с чем-то рядом..." },
|
||||
{ "text": "The network remembers: {element} was useful here...", "textRu": "Сеть помнит: {element} был полезен здесь..." },
|
||||
{ "text": "Traces of {element} flow through the mycelium threads...", "textRu": "Следы {element} текут по нитям мицелия..." }
|
||||
],
|
||||
"reaction_hint": [
|
||||
{ "text": "A past adept combined... the result was {compound}...", "textRu": "Прошлый адепт комбинировал... результат — {compound}..." },
|
||||
{ "text": "The fungal memory shows: mixing yields {compound}...", "textRu": "Грибная память показывает: смешение даёт {compound}..." },
|
||||
{ "text": "Chemical echoes: a reaction produced {compound} here...", "textRu": "Химическое эхо: реакция создала {compound} здесь..." }
|
||||
],
|
||||
"creature_hint": [
|
||||
{ "text": "Beware: {creature} was observed in this region...", "textRu": "Осторожно: {creature} замечен в этом регионе..." },
|
||||
{ "text": "The network felt vibrations... {creature} territory...", "textRu": "Сеть ощутила вибрации... территория {creature}..." }
|
||||
],
|
||||
"lore": [
|
||||
{ "text": "...the cycle continues. What dies feeds what grows...", "textRu": "...цикл продолжается. Что умирает, питает растущее..." },
|
||||
{ "text": "...synthesis is not creation. It is transformation...", "textRu": "...синтез — не создание. Это трансформация..." },
|
||||
{ "text": "...the Mycelium remembers all who walked these paths...", "textRu": "...Мицелий помнит всех, кто шёл этими тропами..." }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -83,3 +83,12 @@ export const LifeCycle = {
|
||||
stageTimer: [] as number[], // ms remaining in current stage
|
||||
age: [] as number[], // total age in ms
|
||||
};
|
||||
|
||||
// ─── Mycelium Components ─────────────────────────────────────────
|
||||
|
||||
/** Fungal node — a point where the Mycelium surfaces on the map */
|
||||
export const FungalNode = {
|
||||
nodeIndex: [] as number[], // index in fungal node info list
|
||||
glowPhase: [] as number[], // animation phase (radians)
|
||||
interactRange: [] as number[], // max interaction distance in pixels
|
||||
};
|
||||
|
||||
227
src/mycelium/graph.ts
Normal file
227
src/mycelium/graph.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Mycelium Graph — persistent knowledge network
|
||||
*
|
||||
* The graph grows as the player deposits discoveries at fungal nodes.
|
||||
* Nodes represent knowledge (elements, reactions, compounds, creatures).
|
||||
* Edges represent relationships (e.g., elements that form a reaction).
|
||||
*
|
||||
* Strength increases with repeated deposits, decays slightly over time
|
||||
* (knowledge fades if not reinforced).
|
||||
*/
|
||||
|
||||
import type { MyceliumGraphData, MyceliumNodeData, MyceliumEdgeData, RunState } from '../run/types';
|
||||
import type { DepositResult } from './types';
|
||||
|
||||
/** Strength increment per deposit (diminishing returns via current strength) */
|
||||
const STRENGTH_INCREMENT = 0.2;
|
||||
/** Initial strength for a newly created node */
|
||||
const INITIAL_STRENGTH = 0.3;
|
||||
/** Edge weight for related discoveries */
|
||||
const EDGE_WEIGHT = 0.5;
|
||||
|
||||
/** Create a new empty Mycelium graph */
|
||||
export function createMyceliumGraph(): MyceliumGraphData {
|
||||
return {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
totalDeposits: 0,
|
||||
totalExtractions: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deposit all discoveries from a run into the Mycelium graph.
|
||||
*
|
||||
* - Creates new nodes for unknown discoveries
|
||||
* - Strengthens existing nodes for repeated discoveries
|
||||
* - Creates edges between related knowledge (elements↔reactions, reactions↔compounds)
|
||||
*/
|
||||
export function depositKnowledge(
|
||||
graph: MyceliumGraphData,
|
||||
run: RunState,
|
||||
): DepositResult {
|
||||
let newNodes = 0;
|
||||
let strengthened = 0;
|
||||
let newEdges = 0;
|
||||
|
||||
const addedNodeIds: string[] = [];
|
||||
|
||||
// Deposit elements
|
||||
for (const elemId of run.discoveries.elements) {
|
||||
const nodeId = `element:${elemId}`;
|
||||
const result = upsertNode(graph, nodeId, 'element', elemId, run.runId);
|
||||
if (result === 'created') newNodes++;
|
||||
else strengthened++;
|
||||
addedNodeIds.push(nodeId);
|
||||
}
|
||||
|
||||
// Deposit reactions
|
||||
for (const reactionId of run.discoveries.reactions) {
|
||||
const nodeId = `reaction:${reactionId}`;
|
||||
const result = upsertNode(graph, nodeId, 'reaction', reactionId, run.runId);
|
||||
if (result === 'created') newNodes++;
|
||||
else strengthened++;
|
||||
addedNodeIds.push(nodeId);
|
||||
}
|
||||
|
||||
// Deposit compounds
|
||||
for (const compoundId of run.discoveries.compounds) {
|
||||
const nodeId = `compound:${compoundId}`;
|
||||
const result = upsertNode(graph, nodeId, 'compound', compoundId, run.runId);
|
||||
if (result === 'created') newNodes++;
|
||||
else strengthened++;
|
||||
addedNodeIds.push(nodeId);
|
||||
}
|
||||
|
||||
// Deposit creatures
|
||||
for (const creatureId of run.discoveries.creatures) {
|
||||
const nodeId = `creature:${creatureId}`;
|
||||
const result = upsertNode(graph, nodeId, 'creature', creatureId, run.runId);
|
||||
if (result === 'created') newNodes++;
|
||||
else strengthened++;
|
||||
addedNodeIds.push(nodeId);
|
||||
}
|
||||
|
||||
// Create edges between related discoveries
|
||||
// Elements ↔ Reactions (elements that appear in a reaction key)
|
||||
for (const reactionId of run.discoveries.reactions) {
|
||||
const reactionNodeId = `reaction:${reactionId}`;
|
||||
// Parse reaction key (e.g., "Na+Cl" → ["Na", "Cl"])
|
||||
const reactants = reactionId.split('+');
|
||||
for (const reactant of reactants) {
|
||||
const elemNodeId = `element:${reactant}`;
|
||||
if (getNode(graph, elemNodeId)) {
|
||||
const added = upsertEdge(graph, elemNodeId, reactionNodeId);
|
||||
if (added) newEdges++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reactions ↔ Compounds (reaction produces compound)
|
||||
for (const reactionId of run.discoveries.reactions) {
|
||||
const reactionNodeId = `reaction:${reactionId}`;
|
||||
for (const compoundId of run.discoveries.compounds) {
|
||||
const compoundNodeId = `compound:${compoundId}`;
|
||||
const added = upsertEdge(graph, reactionNodeId, compoundNodeId);
|
||||
if (added) newEdges++;
|
||||
}
|
||||
}
|
||||
|
||||
graph.totalDeposits++;
|
||||
|
||||
return { newNodes, strengthened, newEdges };
|
||||
}
|
||||
|
||||
/** Get a node by its ID */
|
||||
export function getNode(
|
||||
graph: MyceliumGraphData,
|
||||
nodeId: string,
|
||||
): MyceliumNodeData | undefined {
|
||||
return graph.nodes.find(n => n.id === nodeId);
|
||||
}
|
||||
|
||||
/** Get an edge between two nodes */
|
||||
export function getEdge(
|
||||
graph: MyceliumGraphData,
|
||||
fromId: string,
|
||||
toId: string,
|
||||
): MyceliumEdgeData | undefined {
|
||||
return graph.edges.find(e =>
|
||||
(e.from === fromId && e.to === toId) ||
|
||||
(e.from === toId && e.to === fromId),
|
||||
);
|
||||
}
|
||||
|
||||
/** Get all nodes of a specific type */
|
||||
export function getNodesByType(
|
||||
graph: MyceliumGraphData,
|
||||
type: MyceliumNodeData['type'],
|
||||
): MyceliumNodeData[] {
|
||||
return graph.nodes.filter(n => n.type === type);
|
||||
}
|
||||
|
||||
/** Get nodes connected to a given node (via edges) */
|
||||
export function getConnectedNodes(
|
||||
graph: MyceliumGraphData,
|
||||
nodeId: string,
|
||||
): MyceliumNodeData[] {
|
||||
const connectedIds = new Set<string>();
|
||||
|
||||
for (const edge of graph.edges) {
|
||||
if (edge.from === nodeId) connectedIds.add(edge.to);
|
||||
if (edge.to === nodeId) connectedIds.add(edge.from);
|
||||
}
|
||||
|
||||
return graph.nodes.filter(n => connectedIds.has(n.id));
|
||||
}
|
||||
|
||||
/** Get summary statistics of the graph */
|
||||
export function getGraphStats(graph: MyceliumGraphData): {
|
||||
nodeCount: number;
|
||||
edgeCount: number;
|
||||
totalDeposits: number;
|
||||
totalExtractions: number;
|
||||
averageStrength: number;
|
||||
} {
|
||||
const avgStrength = graph.nodes.length > 0
|
||||
? graph.nodes.reduce((sum, n) => sum + n.strength, 0) / graph.nodes.length
|
||||
: 0;
|
||||
|
||||
return {
|
||||
nodeCount: graph.nodes.length,
|
||||
edgeCount: graph.edges.length,
|
||||
totalDeposits: graph.totalDeposits,
|
||||
totalExtractions: graph.totalExtractions,
|
||||
averageStrength: avgStrength,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Internal helpers ────────────────────────────────────────────
|
||||
|
||||
/** Insert or update a knowledge node. Returns 'created' or 'strengthened'. */
|
||||
function upsertNode(
|
||||
graph: MyceliumGraphData,
|
||||
nodeId: string,
|
||||
type: MyceliumNodeData['type'],
|
||||
knowledgeId: string,
|
||||
runId: number,
|
||||
): 'created' | 'strengthened' {
|
||||
const existing = getNode(graph, nodeId);
|
||||
if (existing) {
|
||||
// Diminishing returns: delta = INCREMENT * (1 - currentStrength)
|
||||
const delta = STRENGTH_INCREMENT * (1 - existing.strength);
|
||||
existing.strength = Math.min(1.0, existing.strength + delta);
|
||||
return 'strengthened';
|
||||
}
|
||||
|
||||
graph.nodes.push({
|
||||
id: nodeId,
|
||||
type,
|
||||
knowledgeId,
|
||||
depositedOnRun: runId,
|
||||
strength: INITIAL_STRENGTH,
|
||||
});
|
||||
|
||||
return 'created';
|
||||
}
|
||||
|
||||
/** Insert or strengthen an edge. Returns true if newly created. */
|
||||
function upsertEdge(
|
||||
graph: MyceliumGraphData,
|
||||
fromId: string,
|
||||
toId: string,
|
||||
): boolean {
|
||||
const existing = getEdge(graph, fromId, toId);
|
||||
if (existing) {
|
||||
existing.weight = Math.min(1.0, existing.weight + 0.1);
|
||||
return false;
|
||||
}
|
||||
|
||||
graph.edges.push({
|
||||
from: fromId,
|
||||
to: toId,
|
||||
weight: EDGE_WEIGHT,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
13
src/mycelium/index.ts
Normal file
13
src/mycelium/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Mycelium Module — the underground fungal network connecting runs
|
||||
*
|
||||
* Re-exports all public API from mycelium subsystems.
|
||||
*/
|
||||
|
||||
export { createMyceliumGraph, depositKnowledge, getNode, getEdge, getNodesByType, getConnectedNodes, getGraphStats } from './graph';
|
||||
export { extractMemoryFlashes, generateFlashText } from './knowledge';
|
||||
export { createMycosisState, updateMycosis, getMycosisVisuals } from './mycosis';
|
||||
export { getAvailableBonuses, purchaseBonus, canAffordBonus, resetShopSession } from './shop';
|
||||
export { spawnFungalNodes } from './nodes';
|
||||
export type { MyceliumKnowledgeNode, MyceliumEdge, MyceliumGraph, MemoryFlash, MycosisState, FungalNodeInfo, SporeBonus, BonusEffect, DepositResult, ExtractResult } from './types';
|
||||
export type { MycosisVisuals } from './mycosis';
|
||||
158
src/mycelium/knowledge.ts
Normal file
158
src/mycelium/knowledge.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Knowledge System — deposit and extract memories via the Mycelium
|
||||
*
|
||||
* When a player interacts with a fungal node:
|
||||
* - Deposit: current run discoveries are added to the Mycelium graph
|
||||
* - Extract: memory flashes from past runs are surfaced
|
||||
*
|
||||
* Stronger nodes produce clearer, more useful memories.
|
||||
*/
|
||||
|
||||
import type { MyceliumGraphData, MyceliumNodeData } from '../run/types';
|
||||
import type { MemoryFlash } from './types';
|
||||
import myceliumData from '../data/mycelium.json';
|
||||
|
||||
const templates = myceliumData.memoryTemplates;
|
||||
|
||||
/**
|
||||
* Extract memory flashes from the Mycelium graph.
|
||||
*
|
||||
* Selects nodes weighted by strength, generates flash text
|
||||
* for each. Stronger nodes produce higher-clarity flashes.
|
||||
*
|
||||
* @param graph The persistent Mycelium graph
|
||||
* @param maxFlashes Maximum number of flashes to return
|
||||
* @returns Array of memory flashes
|
||||
*/
|
||||
export function extractMemoryFlashes(
|
||||
graph: MyceliumGraphData,
|
||||
maxFlashes: number,
|
||||
): MemoryFlash[] {
|
||||
if (graph.nodes.length === 0) return [];
|
||||
|
||||
graph.totalExtractions++;
|
||||
|
||||
// Weighted random selection: higher strength = more likely
|
||||
const selected = weightedSelect(graph.nodes, maxFlashes);
|
||||
|
||||
return selected.map(node => {
|
||||
const flash = generateFlashText(node.type, node.knowledgeId);
|
||||
flash.sourceRunId = node.depositedOnRun;
|
||||
flash.clarity = node.strength;
|
||||
return flash;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a memory flash text from templates.
|
||||
*
|
||||
* Replaces {element}, {compound}, {creature} placeholders
|
||||
* with the actual knowledge ID.
|
||||
*/
|
||||
export function generateFlashText(
|
||||
type: MyceliumNodeData['type'],
|
||||
knowledgeId: string,
|
||||
): MemoryFlash {
|
||||
const templateKey = getTemplateKey(type);
|
||||
const templateList = templates[templateKey];
|
||||
|
||||
// Deterministic-ish template selection based on knowledge ID hash
|
||||
const hash = simpleHash(knowledgeId);
|
||||
const template = templateList[hash % templateList.length];
|
||||
|
||||
const text = replaceTokens(template.text, type, knowledgeId);
|
||||
const textRu = replaceTokens(template.textRu, type, knowledgeId);
|
||||
|
||||
return {
|
||||
type: templateKey,
|
||||
text,
|
||||
textRu,
|
||||
sourceRunId: 0,
|
||||
clarity: 0.5,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Internal helpers ────────────────────────────────────────────
|
||||
|
||||
/** Map node type to template key */
|
||||
function getTemplateKey(type: MyceliumNodeData['type']): MemoryFlash['type'] {
|
||||
switch (type) {
|
||||
case 'element': return 'element_hint';
|
||||
case 'reaction': return 'reaction_hint';
|
||||
case 'compound': return 'reaction_hint'; // compounds use reaction templates
|
||||
case 'creature': return 'creature_hint';
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace template tokens with actual values */
|
||||
function replaceTokens(
|
||||
template: string,
|
||||
type: MyceliumNodeData['type'],
|
||||
knowledgeId: string,
|
||||
): string {
|
||||
return template
|
||||
.replace('{element}', knowledgeId)
|
||||
.replace('{compound}', knowledgeId)
|
||||
.replace('{creature}', knowledgeId);
|
||||
}
|
||||
|
||||
/** Simple hash for deterministic template selection */
|
||||
function simpleHash(str: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Weighted random selection without replacement.
|
||||
* Nodes with higher strength are more likely to be selected.
|
||||
*/
|
||||
function weightedSelect(
|
||||
nodes: MyceliumNodeData[],
|
||||
count: number,
|
||||
): MyceliumNodeData[] {
|
||||
if (nodes.length <= count) return [...nodes];
|
||||
|
||||
const result: MyceliumNodeData[] = [];
|
||||
const used = new Set<number>();
|
||||
|
||||
// Build cumulative weight array
|
||||
const weights = nodes.map(n => n.strength + 0.1); // small base weight
|
||||
const totalWeight = weights.reduce((a, b) => a + b, 0);
|
||||
|
||||
for (let i = 0; i < count && used.size < nodes.length; i++) {
|
||||
let target = pseudoRandom(i + nodes.length) * totalWeight;
|
||||
let idx = 0;
|
||||
|
||||
while (idx < nodes.length) {
|
||||
if (!used.has(idx)) {
|
||||
target -= weights[idx];
|
||||
if (target <= 0) break;
|
||||
}
|
||||
idx++;
|
||||
}
|
||||
|
||||
// Safety: if we went past the end, find first unused
|
||||
if (idx >= nodes.length) {
|
||||
idx = 0;
|
||||
while (used.has(idx) && idx < nodes.length) idx++;
|
||||
}
|
||||
|
||||
if (idx < nodes.length) {
|
||||
result.push(nodes[idx]);
|
||||
used.add(idx);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Pseudo-random 0–1 from integer seed (mulberry32-like) */
|
||||
function pseudoRandom(seed: number): number {
|
||||
let t = (seed + 0x6d2b79f5) | 0;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
}
|
||||
83
src/mycelium/mycosis.ts
Normal file
83
src/mycelium/mycosis.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Mycosis — visual distortion from prolonged contact with fungal nodes
|
||||
*
|
||||
* When the player stays near a fungal node, mycosis level increases.
|
||||
* At the reveal threshold, hidden information becomes visible
|
||||
* (stronger memory flashes, secret details about nearby creatures).
|
||||
*
|
||||
* Moving away from nodes causes mycosis to slowly decay.
|
||||
*/
|
||||
|
||||
import type { MycosisState } from './types';
|
||||
import { MYCOSIS_CONFIG } from './types';
|
||||
|
||||
/** Create a fresh mycosis state (no fungal influence) */
|
||||
export function createMycosisState(): MycosisState {
|
||||
return {
|
||||
level: 0,
|
||||
exposure: 0,
|
||||
revealing: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update mycosis state based on proximity to fungal nodes.
|
||||
*
|
||||
* @param state Current mycosis state (mutated in place)
|
||||
* @param deltaMs Time elapsed since last update (ms)
|
||||
* @param nearNode Whether the player is currently near a fungal node
|
||||
*/
|
||||
export function updateMycosis(
|
||||
state: MycosisState,
|
||||
deltaMs: number,
|
||||
nearNode: boolean,
|
||||
): void {
|
||||
const deltaSec = deltaMs / 1000;
|
||||
|
||||
if (nearNode) {
|
||||
// Build up mycosis
|
||||
state.exposure += deltaMs;
|
||||
state.level = Math.min(
|
||||
MYCOSIS_CONFIG.maxLevel,
|
||||
state.level + MYCOSIS_CONFIG.buildRate * deltaSec,
|
||||
);
|
||||
} else {
|
||||
// Decay mycosis
|
||||
state.level = Math.max(
|
||||
0,
|
||||
state.level - MYCOSIS_CONFIG.decayRate * deltaSec,
|
||||
);
|
||||
}
|
||||
|
||||
// Update reveal state
|
||||
state.revealing = state.level >= MYCOSIS_CONFIG.revealThreshold;
|
||||
}
|
||||
|
||||
/** Visual parameters derived from mycosis level */
|
||||
export interface MycosisVisuals {
|
||||
/** Tint overlay color */
|
||||
tintColor: number;
|
||||
/** Tint overlay alpha (0 = invisible, maxTintAlpha = full) */
|
||||
tintAlpha: number;
|
||||
/** Strength of visual distortion (0 = none, 1 = maximum) */
|
||||
distortionStrength: number;
|
||||
/** Whether hidden info should be shown */
|
||||
revealing: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get visual parameters for rendering the mycosis effect.
|
||||
*
|
||||
* @param state Current mycosis state
|
||||
* @returns Visual parameters for the renderer
|
||||
*/
|
||||
export function getMycosisVisuals(state: MycosisState): MycosisVisuals {
|
||||
const fraction = state.level / MYCOSIS_CONFIG.maxLevel;
|
||||
|
||||
return {
|
||||
tintColor: MYCOSIS_CONFIG.tintColor,
|
||||
tintAlpha: fraction * MYCOSIS_CONFIG.maxTintAlpha,
|
||||
distortionStrength: fraction,
|
||||
revealing: state.revealing,
|
||||
};
|
||||
}
|
||||
131
src/mycelium/nodes.ts
Normal file
131
src/mycelium/nodes.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Fungal Node Spawning — places Mycelium surface points on the world map
|
||||
*
|
||||
* Fungal nodes are ECS entities placed on walkable tiles during world generation.
|
||||
* They glow softly (bioluminescent green) and pulse gently.
|
||||
* Players interact with them to deposit/extract knowledge.
|
||||
*
|
||||
* Placement uses a Poisson-disc-like approach for even spacing.
|
||||
*/
|
||||
|
||||
import { addEntity, addComponent } from 'bitecs';
|
||||
import type { World } from '../ecs/world';
|
||||
import { Position, FungalNode, SpriteRef } from '../ecs/components';
|
||||
import type { BiomeData, TileGrid } from '../world/types';
|
||||
import type { FungalNodeInfo } from './types';
|
||||
import { FUNGAL_NODE_CONFIG } from './types';
|
||||
|
||||
/**
|
||||
* Spawn fungal nodes on the world map.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Scan grid for tiles listed in FUNGAL_NODE_CONFIG.spawnOnTiles
|
||||
* 2. Deterministically select candidate positions using seed
|
||||
* 3. Filter by minimum spacing constraint
|
||||
* 4. Create ECS entities at selected positions
|
||||
*
|
||||
* @returns Map of entity ID → FungalNodeInfo
|
||||
*/
|
||||
export function spawnFungalNodes(
|
||||
world: World,
|
||||
grid: TileGrid,
|
||||
biome: BiomeData,
|
||||
seed: number,
|
||||
): Map<number, FungalNodeInfo> {
|
||||
const nodeData = new Map<number, FungalNodeInfo>();
|
||||
const tileSize = biome.tileSize;
|
||||
|
||||
// Find tile IDs that fungal nodes can spawn on
|
||||
const spawnTileIds = new Set<number>();
|
||||
for (const tileName of FUNGAL_NODE_CONFIG.spawnOnTiles) {
|
||||
const tile = biome.tiles.find(t => t.name === tileName);
|
||||
if (tile) spawnTileIds.add(tile.id);
|
||||
}
|
||||
|
||||
// Collect all candidate positions (walkable tiles that match spawn targets)
|
||||
const candidates: { x: number; y: number }[] = [];
|
||||
for (let y = 0; y < grid.length; y++) {
|
||||
for (let x = 0; x < grid[y].length; x++) {
|
||||
if (spawnTileIds.has(grid[y][x])) {
|
||||
candidates.push({ x, y });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 0) return nodeData;
|
||||
|
||||
// Determine target node count from map size
|
||||
const mapArea = biome.mapWidth * biome.mapHeight;
|
||||
const targetCount = Math.max(1, Math.round(mapArea * FUNGAL_NODE_CONFIG.targetDensity));
|
||||
|
||||
// Deterministic placement using seed-based scoring
|
||||
const scored = candidates.map(c => ({
|
||||
...c,
|
||||
score: deterministicScore(c.x, c.y, seed),
|
||||
}));
|
||||
|
||||
// Sort by score (highest first) — deterministic ordering
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
|
||||
// Select nodes respecting minimum spacing
|
||||
const placed: { x: number; y: number }[] = [];
|
||||
const minSpacing = FUNGAL_NODE_CONFIG.minSpacing;
|
||||
|
||||
for (const candidate of scored) {
|
||||
if (placed.length >= targetCount) break;
|
||||
|
||||
// Check spacing against all placed nodes
|
||||
let tooClose = false;
|
||||
for (const existing of placed) {
|
||||
const dx = candidate.x - existing.x;
|
||||
const dy = candidate.y - existing.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < minSpacing) {
|
||||
tooClose = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!tooClose) {
|
||||
placed.push({ x: candidate.x, y: candidate.y });
|
||||
}
|
||||
}
|
||||
|
||||
// Create ECS entities for placed nodes
|
||||
for (let i = 0; i < placed.length; i++) {
|
||||
const pos = placed[i];
|
||||
const eid = addEntity(world);
|
||||
|
||||
addComponent(world, eid, Position);
|
||||
addComponent(world, eid, FungalNode);
|
||||
addComponent(world, eid, SpriteRef);
|
||||
|
||||
Position.x[eid] = pos.x * tileSize + tileSize / 2;
|
||||
Position.y[eid] = pos.y * tileSize + tileSize / 2;
|
||||
|
||||
FungalNode.nodeIndex[eid] = i;
|
||||
FungalNode.glowPhase[eid] = deterministicScore(pos.x, pos.y, seed + 1) * Math.PI * 2;
|
||||
FungalNode.interactRange[eid] = FUNGAL_NODE_CONFIG.interactRange;
|
||||
|
||||
SpriteRef.color[eid] = FUNGAL_NODE_CONFIG.spriteColor;
|
||||
SpriteRef.radius[eid] = FUNGAL_NODE_CONFIG.spriteRadius;
|
||||
|
||||
nodeData.set(eid, {
|
||||
tileX: pos.x,
|
||||
tileY: pos.y,
|
||||
nodeIndex: i,
|
||||
});
|
||||
}
|
||||
|
||||
return nodeData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic score for a tile position (0–1).
|
||||
* Higher scores are selected first for node placement.
|
||||
*/
|
||||
function deterministicScore(x: number, y: number, seed: number): number {
|
||||
// Multiplicative hash for spatial distribution
|
||||
const hash = ((x * 73856093) ^ (y * 19349663) ^ (seed * 83492791)) >>> 0;
|
||||
return (hash % 1000000) / 1000000;
|
||||
}
|
||||
65
src/mycelium/shop.ts
Normal file
65
src/mycelium/shop.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Spore Shop — spend spores for starting bonuses at the Cradle
|
||||
*
|
||||
* Before each run, the player can visit fungal nodes in the Cradle
|
||||
* to exchange spores for:
|
||||
* - Extra health
|
||||
* - Extra starting elements
|
||||
* - Knowledge boost (clearer memory flashes)
|
||||
*/
|
||||
|
||||
import type { MetaState } from '../run/types';
|
||||
import type { SporeBonus, BonusEffect } from './types';
|
||||
import myceliumData from '../data/mycelium.json';
|
||||
|
||||
const allBonuses = myceliumData.sporeBonuses as SporeBonus[];
|
||||
|
||||
/** Track which non-repeatable bonuses have been purchased this session */
|
||||
const purchasedNonRepeatableThisSession = new Set<string>();
|
||||
|
||||
/** Get all available spore bonuses */
|
||||
export function getAvailableBonuses(): SporeBonus[] {
|
||||
return [...allBonuses];
|
||||
}
|
||||
|
||||
/** Check if the player can afford a specific bonus */
|
||||
export function canAffordBonus(meta: MetaState, bonusId: string): boolean {
|
||||
const bonus = allBonuses.find(b => b.id === bonusId);
|
||||
if (!bonus) return false;
|
||||
if (!bonus.repeatable && purchasedNonRepeatableThisSession.has(bonusId)) return false;
|
||||
return meta.spores >= bonus.cost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Purchase a spore bonus. Deducts spores from meta state.
|
||||
*
|
||||
* @returns The purchased effect, or null if purchase failed
|
||||
*/
|
||||
export function purchaseBonus(
|
||||
meta: MetaState,
|
||||
bonusId: string,
|
||||
): BonusEffect | null {
|
||||
const bonus = allBonuses.find(b => b.id === bonusId);
|
||||
if (!bonus) return null;
|
||||
|
||||
// Check non-repeatable
|
||||
if (!bonus.repeatable && purchasedNonRepeatableThisSession.has(bonusId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check affordability
|
||||
if (meta.spores < bonus.cost) return null;
|
||||
|
||||
// Deduct and record
|
||||
meta.spores -= bonus.cost;
|
||||
if (!bonus.repeatable) {
|
||||
purchasedNonRepeatableThisSession.add(bonusId);
|
||||
}
|
||||
|
||||
return bonus.effect;
|
||||
}
|
||||
|
||||
/** Reset purchased tracking (call at start of each Cradle visit) */
|
||||
export function resetShopSession(): void {
|
||||
purchasedNonRepeatableThisSession.clear();
|
||||
}
|
||||
165
src/mycelium/types.ts
Normal file
165
src/mycelium/types.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Mycelium Types — the underground fungal network connecting runs
|
||||
*
|
||||
* The Mycelium is a persistent knowledge graph that grows across runs.
|
||||
* Physical fungal nodes on the world map serve as access points.
|
||||
* Players deposit discoveries and extract memories from past runs.
|
||||
*/
|
||||
|
||||
// ─── Persistent Graph (stored in MetaState) ─────────────────────
|
||||
|
||||
/** A knowledge node in the Mycelium graph */
|
||||
export interface MyceliumKnowledgeNode {
|
||||
/** Unique node ID (format: "type:id", e.g. "element:Na") */
|
||||
id: string;
|
||||
/** Knowledge category */
|
||||
type: 'element' | 'reaction' | 'compound' | 'creature';
|
||||
/** Reference to the specific knowledge item */
|
||||
knowledgeId: string;
|
||||
/** Run on which this was first deposited */
|
||||
depositedOnRun: number;
|
||||
/** Knowledge strength 0–1 (increases with repeated deposits) */
|
||||
strength: number;
|
||||
}
|
||||
|
||||
/** An edge connecting two knowledge nodes */
|
||||
export interface MyceliumEdge {
|
||||
/** Source node ID */
|
||||
from: string;
|
||||
/** Target node ID */
|
||||
to: string;
|
||||
/** Connection strength 0–1 */
|
||||
weight: number;
|
||||
}
|
||||
|
||||
/** The full persistent Mycelium graph */
|
||||
export interface MyceliumGraph {
|
||||
/** Knowledge nodes */
|
||||
nodes: MyceliumKnowledgeNode[];
|
||||
/** Connections between nodes */
|
||||
edges: MyceliumEdge[];
|
||||
/** Total deposits across all runs */
|
||||
totalDeposits: number;
|
||||
/** Total extractions across all runs */
|
||||
totalExtractions: number;
|
||||
}
|
||||
|
||||
// ─── Memory Flashes (retrieved from past runs) ──────────────────
|
||||
|
||||
/** A memory fragment surfaced by the Mycelium */
|
||||
export interface MemoryFlash {
|
||||
/** What kind of hint this provides */
|
||||
type: 'element_hint' | 'reaction_hint' | 'creature_hint' | 'lore';
|
||||
/** Display text for the player */
|
||||
text: string;
|
||||
/** Display text in Russian */
|
||||
textRu: string;
|
||||
/** Which run deposited this knowledge */
|
||||
sourceRunId: number;
|
||||
/** How clear/strong this memory is (0–1) */
|
||||
clarity: number;
|
||||
}
|
||||
|
||||
// ─── Mycosis (visual distortion state) ──────────────────────────
|
||||
|
||||
/** Player's current mycosis (fungal influence) state */
|
||||
export interface MycosisState {
|
||||
/** Current mycosis intensity 0–1 */
|
||||
level: number;
|
||||
/** Accumulated exposure time near fungal nodes (ms) */
|
||||
exposure: number;
|
||||
/** Whether hidden info is currently being revealed */
|
||||
revealing: boolean;
|
||||
}
|
||||
|
||||
/** Mycosis configuration constants */
|
||||
export const MYCOSIS_CONFIG = {
|
||||
/** How fast mycosis builds up (level per second of exposure) */
|
||||
buildRate: 0.08,
|
||||
/** How fast mycosis decays when away from nodes (level per second) */
|
||||
decayRate: 0.03,
|
||||
/** Mycosis level at which hidden information is revealed */
|
||||
revealThreshold: 0.5,
|
||||
/** Maximum mycosis level */
|
||||
maxLevel: 1.0,
|
||||
/** Tint color for mycosis visual effect (greenish-purple) */
|
||||
tintColor: 0x6644aa,
|
||||
/** Maximum tint alpha at full mycosis */
|
||||
maxTintAlpha: 0.25,
|
||||
} as const;
|
||||
|
||||
// ─── Fungal Nodes (world map entities) ──────────────────────────
|
||||
|
||||
/** String data for a fungal node entity (not stored in ECS) */
|
||||
export interface FungalNodeInfo {
|
||||
/** Tile position */
|
||||
tileX: number;
|
||||
tileY: number;
|
||||
/** Index in the world's fungal node list */
|
||||
nodeIndex: number;
|
||||
}
|
||||
|
||||
/** Configuration for fungal node spawning */
|
||||
export const FUNGAL_NODE_CONFIG = {
|
||||
/** Interaction range in pixels */
|
||||
interactRange: 48,
|
||||
/** Minimum distance between fungal nodes (in tiles) */
|
||||
minSpacing: 8,
|
||||
/** Target number of nodes per map (adjusted by map size) */
|
||||
targetDensity: 0.003,
|
||||
/** Tiles where fungal nodes can spawn */
|
||||
spawnOnTiles: ['ground', 'scorched-earth'],
|
||||
/** Base glow color (bioluminescent green) */
|
||||
glowColor: 0x44ff88,
|
||||
/** Glow pulse speed (radians per second) */
|
||||
glowPulseSpeed: 1.5,
|
||||
/** Base glow radius in pixels */
|
||||
glowRadius: 6,
|
||||
/** Sprite radius */
|
||||
spriteRadius: 5,
|
||||
/** Sprite color */
|
||||
spriteColor: 0x33cc66,
|
||||
} as const;
|
||||
|
||||
// ─── Spore Bonuses (Cradle shop) ─────────────────────────────────
|
||||
|
||||
/** A purchasable bonus at the Spore Cradle */
|
||||
export interface SporeBonus {
|
||||
id: string;
|
||||
name: string;
|
||||
nameRu: string;
|
||||
description: string;
|
||||
descriptionRu: string;
|
||||
/** Spore cost */
|
||||
cost: number;
|
||||
/** Effect applied at run start */
|
||||
effect: BonusEffect;
|
||||
/** Whether this bonus can be purchased multiple times per run */
|
||||
repeatable: boolean;
|
||||
}
|
||||
|
||||
/** Bonus effect types */
|
||||
export type BonusEffect =
|
||||
| { type: 'extra_health'; amount: number }
|
||||
| { type: 'extra_element'; symbol: string; quantity: number }
|
||||
| { type: 'knowledge_boost'; multiplier: number };
|
||||
|
||||
// ─── Deposit / Extract results ───────────────────────────────────
|
||||
|
||||
/** Result of depositing knowledge at a fungal node */
|
||||
export interface DepositResult {
|
||||
/** How many new nodes were added to the graph */
|
||||
newNodes: number;
|
||||
/** How many existing nodes were strengthened */
|
||||
strengthened: number;
|
||||
/** How many new edges were created */
|
||||
newEdges: number;
|
||||
}
|
||||
|
||||
/** Result of extracting knowledge from a fungal node */
|
||||
export interface ExtractResult {
|
||||
/** Memory flashes retrieved */
|
||||
flashes: MemoryFlash[];
|
||||
/** Whether mycosis was triggered/increased */
|
||||
mycosisIncreased: boolean;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export function createMetaState(): MetaState {
|
||||
bestRunTime: 0,
|
||||
bestRunDiscoveries: 0,
|
||||
runHistory: [],
|
||||
mycelium: { nodes: [], edges: [], totalDeposits: 0, totalExtractions: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Uses a simple key-value pattern with a single object store.
|
||||
*/
|
||||
|
||||
import type { MetaState, CodexEntry, RunSummary } from './types';
|
||||
import type { MetaState, CodexEntry, RunSummary, MyceliumGraphData } from './types';
|
||||
import { createMetaState } from './meta';
|
||||
|
||||
const DB_NAME = 'synthesis-meta';
|
||||
@@ -23,6 +23,7 @@ interface SerializedMetaState {
|
||||
bestRunTime: number;
|
||||
bestRunDiscoveries: number;
|
||||
runHistory: RunSummary[];
|
||||
mycelium?: MyceliumGraphData;
|
||||
}
|
||||
|
||||
/** Open (or create) the IndexedDB database */
|
||||
@@ -53,9 +54,15 @@ function serialize(meta: MetaState): SerializedMetaState {
|
||||
bestRunTime: meta.bestRunTime,
|
||||
bestRunDiscoveries: meta.bestRunDiscoveries,
|
||||
runHistory: [...meta.runHistory],
|
||||
mycelium: meta.mycelium,
|
||||
};
|
||||
}
|
||||
|
||||
/** Default empty mycelium graph */
|
||||
const EMPTY_MYCELIUM: MyceliumGraphData = {
|
||||
nodes: [], edges: [], totalDeposits: 0, totalExtractions: 0,
|
||||
};
|
||||
|
||||
/** Deserialize from plain object back to MetaState */
|
||||
function deserialize(data: SerializedMetaState): MetaState {
|
||||
return {
|
||||
@@ -67,6 +74,7 @@ function deserialize(data: SerializedMetaState): MetaState {
|
||||
bestRunTime: data.bestRunTime ?? 0,
|
||||
bestRunDiscoveries: data.bestRunDiscoveries ?? 0,
|
||||
runHistory: data.runHistory ?? [],
|
||||
mycelium: data.mycelium ?? EMPTY_MYCELIUM,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -130,6 +130,44 @@ export interface MetaState {
|
||||
bestRunDiscoveries: number;
|
||||
/** Run history summaries */
|
||||
runHistory: RunSummary[];
|
||||
/** Mycelium knowledge graph (persistent between runs) */
|
||||
mycelium: MyceliumGraphData;
|
||||
}
|
||||
|
||||
/** Serializable Mycelium graph data (stored in MetaState) */
|
||||
export interface MyceliumGraphData {
|
||||
/** Knowledge nodes */
|
||||
nodes: MyceliumNodeData[];
|
||||
/** Connections between nodes */
|
||||
edges: MyceliumEdgeData[];
|
||||
/** Total deposits across all runs */
|
||||
totalDeposits: number;
|
||||
/** Total extractions across all runs */
|
||||
totalExtractions: number;
|
||||
}
|
||||
|
||||
/** A knowledge node in the persistent Mycelium graph */
|
||||
export interface MyceliumNodeData {
|
||||
/** Unique node ID (format: "type:id", e.g. "element:Na") */
|
||||
id: string;
|
||||
/** Knowledge category */
|
||||
type: 'element' | 'reaction' | 'compound' | 'creature';
|
||||
/** Reference to the specific knowledge item */
|
||||
knowledgeId: string;
|
||||
/** Run on which this was first deposited */
|
||||
depositedOnRun: number;
|
||||
/** Knowledge strength 0–1 (increases with repeated deposits) */
|
||||
strength: number;
|
||||
}
|
||||
|
||||
/** An edge connecting two knowledge nodes in the Mycelium */
|
||||
export interface MyceliumEdgeData {
|
||||
/** Source node ID */
|
||||
from: string;
|
||||
/** Target node ID */
|
||||
to: string;
|
||||
/** Connection strength 0–1 */
|
||||
weight: number;
|
||||
}
|
||||
|
||||
export interface RunSummary {
|
||||
|
||||
@@ -11,6 +11,9 @@ import schoolsData from '../data/schools.json';
|
||||
import type { SchoolData, MetaState } from '../run/types';
|
||||
import { isSchoolUnlocked } from '../run/meta';
|
||||
import { GAME_WIDTH, GAME_HEIGHT } from '../config';
|
||||
import { getAvailableBonuses, purchaseBonus, canAffordBonus, resetShopSession } from '../mycelium/shop';
|
||||
import { getGraphStats } from '../mycelium/graph';
|
||||
import type { BonusEffect } from '../mycelium/types';
|
||||
|
||||
const schools = schoolsData as SchoolData[];
|
||||
|
||||
@@ -22,6 +25,9 @@ export class CradleScene extends Phaser.Scene {
|
||||
private particleGraphics!: Phaser.GameObjects.Graphics;
|
||||
private introTimer = 0;
|
||||
private introComplete = false;
|
||||
private purchasedEffects: BonusEffect[] = [];
|
||||
private sporeCountText!: Phaser.GameObjects.Text;
|
||||
private shopContainer!: Phaser.GameObjects.Container;
|
||||
|
||||
constructor() {
|
||||
super({ key: 'CradleScene' });
|
||||
@@ -33,6 +39,8 @@ export class CradleScene extends Phaser.Scene {
|
||||
this.schoolCards = [];
|
||||
this.introTimer = 0;
|
||||
this.introComplete = false;
|
||||
this.purchasedEffects = [];
|
||||
resetShopSession();
|
||||
}
|
||||
|
||||
create(): void {
|
||||
@@ -91,16 +99,25 @@ export class CradleScene extends Phaser.Scene {
|
||||
});
|
||||
|
||||
// Meta info (top-right)
|
||||
const graphStats = getGraphStats(this.meta.mycelium);
|
||||
const metaInfo = [
|
||||
`Споры: ${this.meta.spores}`,
|
||||
`Раны: ${this.meta.totalRuns}`,
|
||||
`Кодекс: ${this.meta.codex.length}`,
|
||||
`Мицелий: ${graphStats.nodeCount} узлов`,
|
||||
].join(' | ');
|
||||
this.add.text(GAME_WIDTH - 12, 12, metaInfo, {
|
||||
fontSize: '11px',
|
||||
color: '#334433',
|
||||
fontFamily: 'monospace',
|
||||
}).setOrigin(1, 0).setDepth(10);
|
||||
|
||||
// Spore count (top-right, below meta info, updates dynamically)
|
||||
this.sporeCountText = this.add.text(GAME_WIDTH - 12, 28, `🍄 Споры: ${this.meta.spores}`, {
|
||||
fontSize: '13px',
|
||||
color: '#44ff88',
|
||||
fontFamily: 'monospace',
|
||||
});
|
||||
this.sporeCountText.setOrigin(1, 0).setDepth(10);
|
||||
}
|
||||
|
||||
private showSchoolSelection(): void {
|
||||
@@ -226,6 +243,11 @@ export class CradleScene extends Phaser.Scene {
|
||||
this.schoolCards.push(container);
|
||||
}
|
||||
|
||||
// Spore shop (bottom area, only if player has spores)
|
||||
if (this.meta.spores > 0) {
|
||||
this.createSporeShop(cx);
|
||||
}
|
||||
|
||||
// Start button hint
|
||||
const hintText = this.add.text(cx, GAME_HEIGHT - 40, '[ Нажми на школу, чтобы начать ран ]', {
|
||||
fontSize: '13px',
|
||||
@@ -245,6 +267,100 @@ export class CradleScene extends Phaser.Scene {
|
||||
});
|
||||
}
|
||||
|
||||
/** Create the spore shop UI below school cards */
|
||||
private createSporeShop(cx: number): void {
|
||||
this.shopContainer = this.add.container(0, 0);
|
||||
this.shopContainer.setDepth(10);
|
||||
|
||||
const shopY = 440;
|
||||
const shopTitle = this.add.text(cx, shopY, '🍄 Дары Мицелия (потрать споры)', {
|
||||
fontSize: '13px',
|
||||
color: '#33aa66',
|
||||
fontFamily: 'monospace',
|
||||
});
|
||||
shopTitle.setOrigin(0.5);
|
||||
this.shopContainer.add(shopTitle);
|
||||
|
||||
const bonuses = getAvailableBonuses();
|
||||
const btnWidth = 200;
|
||||
const btnHeight = 50;
|
||||
const spacing = 10;
|
||||
const totalWidth = bonuses.length * (btnWidth + spacing) - spacing;
|
||||
const startX = cx - totalWidth / 2 + btnWidth / 2;
|
||||
|
||||
for (let i = 0; i < bonuses.length; i++) {
|
||||
const bonus = bonuses[i];
|
||||
const bx = startX + i * (btnWidth + spacing);
|
||||
const by = shopY + 45;
|
||||
|
||||
const bg = this.add.rectangle(bx, by, btnWidth, btnHeight, 0x0a1a0f, 0.85);
|
||||
const canBuy = canAffordBonus(this.meta, bonus.id);
|
||||
const borderColor = canBuy ? 0x33cc66 : 0x333333;
|
||||
bg.setStrokeStyle(1, borderColor);
|
||||
this.shopContainer.add(bg);
|
||||
|
||||
const label = this.add.text(bx, by - 10, bonus.nameRu, {
|
||||
fontSize: '11px',
|
||||
color: canBuy ? '#88ffaa' : '#555555',
|
||||
fontFamily: 'monospace',
|
||||
});
|
||||
label.setOrigin(0.5);
|
||||
this.shopContainer.add(label);
|
||||
|
||||
const costText = this.add.text(bx, by + 10, `${bonus.cost} спор`, {
|
||||
fontSize: '10px',
|
||||
color: canBuy ? '#44aa66' : '#444444',
|
||||
fontFamily: 'monospace',
|
||||
});
|
||||
costText.setOrigin(0.5);
|
||||
this.shopContainer.add(costText);
|
||||
|
||||
if (canBuy) {
|
||||
bg.setInteractive({ useHandCursor: true });
|
||||
bg.on('pointerover', () => bg.setStrokeStyle(2, 0x00ff88));
|
||||
bg.on('pointerout', () => bg.setStrokeStyle(1, 0x33cc66));
|
||||
bg.on('pointerdown', () => {
|
||||
const effect = purchaseBonus(this.meta, bonus.id);
|
||||
if (effect) {
|
||||
this.purchasedEffects.push(effect);
|
||||
this.sporeCountText.setText(`🍄 Споры: ${this.meta.spores}`);
|
||||
|
||||
// Visual feedback
|
||||
bg.setFillStyle(0x1a3a1f, 0.9);
|
||||
label.setColor('#ffffff');
|
||||
costText.setText('✓ куплено');
|
||||
costText.setColor('#00ff88');
|
||||
bg.removeInteractive();
|
||||
|
||||
// Refresh other buttons' affordability
|
||||
this.refreshShopButtons();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fade in shop
|
||||
this.shopContainer.setAlpha(0);
|
||||
this.tweens.add({
|
||||
targets: this.shopContainer,
|
||||
alpha: 1,
|
||||
duration: 600,
|
||||
delay: 800,
|
||||
});
|
||||
}
|
||||
|
||||
/** Refresh shop button states after a purchase */
|
||||
private refreshShopButtons(): void {
|
||||
// Destroy and recreate shop — simplest approach
|
||||
if (this.shopContainer) {
|
||||
this.shopContainer.destroy();
|
||||
}
|
||||
if (this.meta.spores > 0) {
|
||||
this.createSporeShop(GAME_WIDTH / 2);
|
||||
this.shopContainer.setAlpha(1);
|
||||
}
|
||||
}
|
||||
|
||||
private startRun(school: SchoolData): void {
|
||||
// Flash effect
|
||||
this.cameras.main.flash(300, 0, 255, 136);
|
||||
@@ -254,6 +370,7 @@ export class CradleScene extends Phaser.Scene {
|
||||
meta: this.meta,
|
||||
schoolId: school.id,
|
||||
runId: this.meta.totalRuns + 1,
|
||||
purchasedEffects: this.purchasedEffects,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -59,6 +59,15 @@ import {
|
||||
} from '../run/crisis';
|
||||
import { getEscalationEffects } from '../run/escalation';
|
||||
|
||||
// Mycelium imports
|
||||
import { FungalNode } from '../ecs/components';
|
||||
import { spawnFungalNodes } from '../mycelium/nodes';
|
||||
import { depositKnowledge } from '../mycelium/graph';
|
||||
import { extractMemoryFlashes } from '../mycelium/knowledge';
|
||||
import { createMycosisState, updateMycosis, getMycosisVisuals } from '../mycelium/mycosis';
|
||||
import type { FungalNodeInfo, MycosisState, MemoryFlash } from '../mycelium/types';
|
||||
import { FUNGAL_NODE_CONFIG, MYCOSIS_CONFIG } from '../mycelium/types';
|
||||
|
||||
export class GameScene extends Phaser.Scene {
|
||||
private gameWorld!: GameWorld;
|
||||
private bridge!: PhaserBridge;
|
||||
@@ -107,15 +116,31 @@ export class GameScene extends Phaser.Scene {
|
||||
private playerDead = false;
|
||||
private crisisOverlay!: Phaser.GameObjects.Rectangle;
|
||||
|
||||
// Mycelium state
|
||||
private fungalNodeData!: Map<number, FungalNodeInfo>;
|
||||
private mycosisState!: MycosisState;
|
||||
private mycosisOverlay!: Phaser.GameObjects.Rectangle;
|
||||
private memoryFlashText!: Phaser.GameObjects.Text;
|
||||
private memoryFlashTimer = 0;
|
||||
private fungalNodeGlowGraphics!: Phaser.GameObjects.Graphics;
|
||||
private hasDepositedThisRun = false;
|
||||
|
||||
constructor() {
|
||||
super({ key: 'GameScene' });
|
||||
}
|
||||
|
||||
init(data: { meta: MetaState; schoolId: string; runId: number }): void {
|
||||
// Purchased bonuses from Cradle shop
|
||||
private purchasedEffects: import('../mycelium/types').BonusEffect[] = [];
|
||||
|
||||
init(data: { meta: MetaState; schoolId: string; runId: number; purchasedEffects?: import('../mycelium/types').BonusEffect[] }): void {
|
||||
this.meta = data.meta;
|
||||
this.runState = createRunState(data.runId, data.schoolId);
|
||||
this.crisisState = null;
|
||||
this.playerDead = false;
|
||||
this.mycosisState = createMycosisState();
|
||||
this.hasDepositedThisRun = false;
|
||||
this.memoryFlashTimer = 0;
|
||||
this.purchasedEffects = data.purchasedEffects ?? [];
|
||||
}
|
||||
|
||||
create(): void {
|
||||
@@ -142,6 +167,11 @@ export class GameScene extends Phaser.Scene {
|
||||
this.gameWorld.world, worldData.grid, biome, this.worldSeed,
|
||||
);
|
||||
|
||||
// 5b. Spawn fungal nodes (Mycelium surface points)
|
||||
this.fungalNodeData = spawnFungalNodes(
|
||||
this.gameWorld.world, worldData.grid, biome, this.worldSeed,
|
||||
);
|
||||
|
||||
// 6. Initialize creature systems
|
||||
const allSpecies = speciesDataArray as SpeciesData[];
|
||||
this.speciesRegistry = new SpeciesRegistry(allSpecies);
|
||||
@@ -163,8 +193,9 @@ export class GameScene extends Phaser.Scene {
|
||||
this.inventory = new Inventory(500, 20);
|
||||
this.quickSlots = new QuickSlots();
|
||||
|
||||
// Give starting elements from chosen school
|
||||
// Give starting elements from chosen school + apply purchased bonuses
|
||||
this.giveStartingKit();
|
||||
this.applyPurchasedEffects();
|
||||
|
||||
// 9. Camera — follow player, zoom via scroll wheel
|
||||
const worldPixelW = biome.mapWidth * biome.tileSize;
|
||||
@@ -247,6 +278,36 @@ export class GameScene extends Phaser.Scene {
|
||||
this.crisisOverlay.setScrollFactor(0);
|
||||
this.crisisOverlay.setDepth(90);
|
||||
|
||||
// 10b. Mycosis overlay (full-screen tinted rectangle, hidden by default)
|
||||
this.mycosisOverlay = this.add.rectangle(
|
||||
this.cameras.main.width / 2, this.cameras.main.height / 2,
|
||||
this.cameras.main.width, this.cameras.main.height,
|
||||
MYCOSIS_CONFIG.tintColor, 0,
|
||||
);
|
||||
this.mycosisOverlay.setScrollFactor(0);
|
||||
this.mycosisOverlay.setDepth(91);
|
||||
|
||||
// 10c. Fungal node glow graphics (world-space, under entities)
|
||||
this.fungalNodeGlowGraphics = this.add.graphics();
|
||||
this.fungalNodeGlowGraphics.setDepth(2);
|
||||
|
||||
// 10d. Memory flash text (center of screen, fades in/out)
|
||||
this.memoryFlashText = this.add.text(
|
||||
this.cameras.main.width / 2, this.cameras.main.height / 2, '', {
|
||||
fontSize: '16px',
|
||||
color: '#88ffaa',
|
||||
fontFamily: 'monospace',
|
||||
backgroundColor: '#0a1a0fcc',
|
||||
padding: { x: 12, y: 8 },
|
||||
align: 'center',
|
||||
wordWrap: { width: 400 },
|
||||
},
|
||||
);
|
||||
this.memoryFlashText.setScrollFactor(0);
|
||||
this.memoryFlashText.setOrigin(0.5);
|
||||
this.memoryFlashText.setDepth(105);
|
||||
this.memoryFlashText.setAlpha(0);
|
||||
|
||||
// 11. Launch UIScene overlay
|
||||
this.scene.launch('UIScene');
|
||||
|
||||
@@ -261,6 +322,28 @@ export class GameScene extends Phaser.Scene {
|
||||
};
|
||||
}
|
||||
|
||||
/** Apply purchased spore bonuses from the Cradle shop */
|
||||
private applyPurchasedEffects(): void {
|
||||
for (const effect of this.purchasedEffects) {
|
||||
switch (effect.type) {
|
||||
case 'extra_health':
|
||||
Health.max[this.playerEid] = (Health.max[this.playerEid] ?? 100) + effect.amount;
|
||||
Health.current[this.playerEid] = Health.max[this.playerEid];
|
||||
break;
|
||||
case 'extra_element':
|
||||
for (let i = 0; i < effect.quantity; i++) {
|
||||
this.inventory.addItem(effect.symbol);
|
||||
}
|
||||
this.quickSlots.autoAssign(effect.symbol);
|
||||
break;
|
||||
case 'knowledge_boost':
|
||||
// Stored for use when extracting memory flashes
|
||||
// (increases clarity of extracted flashes)
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Give the player their school's starting elements */
|
||||
private giveStartingKit(): void {
|
||||
const schools = schoolsData as SchoolData[];
|
||||
@@ -323,23 +406,30 @@ export class GameScene extends Phaser.Scene {
|
||||
this.projectileData,
|
||||
);
|
||||
|
||||
// 7. Resource interaction (E key, debounced)
|
||||
// 7. E key interaction (debounced) — fungal node has priority, then resources
|
||||
const isEDown = this.keys.E.isDown;
|
||||
const justPressedE = isEDown && !this.wasEDown;
|
||||
this.wasEDown = isEDown;
|
||||
const interaction = interactionSystem(
|
||||
this.gameWorld.world, justPressedE, this.inventory, this.resourceData,
|
||||
);
|
||||
if (interaction) {
|
||||
// Auto-assign collected items to quick slots
|
||||
if (interaction.type === 'collected' || interaction.type === 'depleted') {
|
||||
if (interaction.itemId) {
|
||||
this.quickSlots.autoAssign(interaction.itemId);
|
||||
// Record element discovery
|
||||
recordDiscovery(this.runState, 'element', interaction.itemId);
|
||||
|
||||
// Check if player is near a fungal node first
|
||||
const nearbyFungalNode = this.getNearestFungalNode();
|
||||
if (justPressedE && nearbyFungalNode !== null) {
|
||||
// Fungal node interaction takes priority
|
||||
this.interactWithFungalNode(nearbyFungalNode);
|
||||
} else {
|
||||
// Normal resource interaction
|
||||
const interaction = interactionSystem(
|
||||
this.gameWorld.world, justPressedE, this.inventory, this.resourceData,
|
||||
);
|
||||
if (interaction) {
|
||||
if (interaction.type === 'collected' || interaction.type === 'depleted') {
|
||||
if (interaction.itemId) {
|
||||
this.quickSlots.autoAssign(interaction.itemId);
|
||||
recordDiscovery(this.runState, 'element', interaction.itemId);
|
||||
}
|
||||
}
|
||||
this.showInteractionFeedback(interaction.type, interaction.itemId);
|
||||
}
|
||||
this.showInteractionFeedback(interaction.type, interaction.itemId);
|
||||
}
|
||||
|
||||
// 8. Quick slot selection (1-4 keys)
|
||||
@@ -396,7 +486,10 @@ export class GameScene extends Phaser.Scene {
|
||||
// 9f. Creature attacks on player
|
||||
creatureAttackPlayerSystem(this.gameWorld.world, this.speciesLookup);
|
||||
|
||||
// 9g. Crisis damage (if active)
|
||||
// 9g. Mycelium — fungal node glow, interaction, mycosis
|
||||
this.updateMycelium(delta);
|
||||
|
||||
// 9h. Crisis damage (if active)
|
||||
if (this.crisisState?.active) {
|
||||
applyCrisisDamage(this.crisisState, delta);
|
||||
const crisisDmg = getCrisisPlayerDamage(this.crisisState, delta);
|
||||
@@ -411,7 +504,7 @@ export class GameScene extends Phaser.Scene {
|
||||
this.crisisOverlay.setFillStyle(tint.color, tint.alpha);
|
||||
}
|
||||
|
||||
// 9h. Environmental damage from high escalation
|
||||
// 9i. Environmental damage from high escalation
|
||||
const escalationFx = getEscalationEffects(this.runState.escalation);
|
||||
if (escalationFx.environmentalDamage > 0) {
|
||||
const envDmg = escalationFx.environmentalDamage * (delta / 1000);
|
||||
@@ -527,6 +620,109 @@ export class GameScene extends Phaser.Scene {
|
||||
}
|
||||
}
|
||||
|
||||
/** Find the nearest fungal node within interaction range, or null */
|
||||
private getNearestFungalNode(): number | null {
|
||||
const px = Position.x[this.playerEid];
|
||||
const py = Position.y[this.playerEid];
|
||||
const nodeEids = query(this.gameWorld.world, [Position, FungalNode]);
|
||||
|
||||
let nearestEid: number | null = null;
|
||||
let nearestDist = Infinity;
|
||||
|
||||
for (const eid of nodeEids) {
|
||||
const dx = Position.x[eid] - px;
|
||||
const dy = Position.y[eid] - py;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist <= FungalNode.interactRange[eid] && dist < nearestDist) {
|
||||
nearestEid = eid;
|
||||
nearestDist = dist;
|
||||
}
|
||||
}
|
||||
|
||||
return nearestEid;
|
||||
}
|
||||
|
||||
/** Mycelium system — glow animation, mycosis update, memory flash fade */
|
||||
private updateMycelium(delta: number): void {
|
||||
// Check proximity to any fungal node (for mycosis)
|
||||
const nearNode = this.getNearestFungalNode() !== null;
|
||||
|
||||
// Update mycosis
|
||||
updateMycosis(this.mycosisState, delta, nearNode);
|
||||
const visuals = getMycosisVisuals(this.mycosisState);
|
||||
this.mycosisOverlay.setFillStyle(visuals.tintColor, visuals.tintAlpha);
|
||||
|
||||
// Render fungal node glows
|
||||
const nodeEids = query(this.gameWorld.world, [Position, FungalNode]);
|
||||
this.fungalNodeGlowGraphics.clear();
|
||||
for (const eid of nodeEids) {
|
||||
FungalNode.glowPhase[eid] += FUNGAL_NODE_CONFIG.glowPulseSpeed * (delta / 1000);
|
||||
const phase = FungalNode.glowPhase[eid];
|
||||
const pulse = 0.4 + 0.6 * Math.sin(phase);
|
||||
const radius = FUNGAL_NODE_CONFIG.glowRadius * (0.8 + 0.4 * pulse);
|
||||
const alpha = 0.15 + 0.15 * pulse;
|
||||
|
||||
this.fungalNodeGlowGraphics.fillStyle(FUNGAL_NODE_CONFIG.glowColor, alpha);
|
||||
this.fungalNodeGlowGraphics.fillCircle(
|
||||
Position.x[eid], Position.y[eid], radius,
|
||||
);
|
||||
// Inner brighter glow
|
||||
this.fungalNodeGlowGraphics.fillStyle(FUNGAL_NODE_CONFIG.glowColor, alpha * 1.5);
|
||||
this.fungalNodeGlowGraphics.fillCircle(
|
||||
Position.x[eid], Position.y[eid], radius * 0.5,
|
||||
);
|
||||
}
|
||||
|
||||
// Fade memory flash text
|
||||
if (this.memoryFlashTimer > 0) {
|
||||
this.memoryFlashTimer -= delta;
|
||||
if (this.memoryFlashTimer <= 500) {
|
||||
this.memoryFlashText.setAlpha(this.memoryFlashTimer / 500);
|
||||
}
|
||||
if (this.memoryFlashTimer <= 0) {
|
||||
this.memoryFlashText.setAlpha(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Push mycosis data to registry for UIScene
|
||||
this.registry.set('mycosisLevel', this.mycosisState.level);
|
||||
this.registry.set('mycosisRevealing', this.mycosisState.revealing);
|
||||
}
|
||||
|
||||
/** Handle interaction with a fungal node */
|
||||
private interactWithFungalNode(nodeEid: number): void {
|
||||
// First time interacting in this run → deposit discoveries
|
||||
if (!this.hasDepositedThisRun) {
|
||||
const result = depositKnowledge(this.meta.mycelium, this.runState);
|
||||
this.hasDepositedThisRun = true;
|
||||
|
||||
const msg = result.newNodes > 0
|
||||
? `🍄 Мицелий принял ${result.newNodes} знани${result.newNodes === 1 ? 'е' : 'й'}`
|
||||
: `🍄 Мицелий укрепил ${result.strengthened} связ${result.strengthened === 1 ? 'ь' : 'ей'}`;
|
||||
this.showInteractionFeedback('collected', msg);
|
||||
}
|
||||
|
||||
// Extract memory flashes from past runs
|
||||
if (this.meta.mycelium.nodes.length > 0) {
|
||||
const flashes = extractMemoryFlashes(this.meta.mycelium, 2);
|
||||
if (flashes.length > 0) {
|
||||
this.showMemoryFlash(flashes[0]);
|
||||
}
|
||||
} else {
|
||||
this.showInteractionFeedback('collected', '🍄 Мицелий пуст... пока.');
|
||||
}
|
||||
}
|
||||
|
||||
/** Display a memory flash on screen */
|
||||
private showMemoryFlash(flash: MemoryFlash): void {
|
||||
// Show Russian text with clarity indicator
|
||||
const clarityStars = flash.clarity >= 0.7 ? '★★★' : flash.clarity >= 0.4 ? '★★☆' : '★☆☆';
|
||||
const text = `${flash.textRu}\n\n[${clarityStars}]`;
|
||||
this.memoryFlashText.setText(text);
|
||||
this.memoryFlashText.setAlpha(1);
|
||||
this.memoryFlashTimer = 4000; // Show for 4 seconds
|
||||
}
|
||||
|
||||
/** Manage run phase transitions and escalation */
|
||||
private updateRunPhase(delta: number): void {
|
||||
const phase = this.runState.phase;
|
||||
@@ -576,6 +772,12 @@ export class GameScene extends Phaser.Scene {
|
||||
this.playerDead = true;
|
||||
this.runState.alive = false;
|
||||
|
||||
// Auto-deposit all discoveries into Mycelium on death
|
||||
if (!this.hasDepositedThisRun) {
|
||||
depositKnowledge(this.meta.mycelium, this.runState);
|
||||
this.hasDepositedThisRun = true;
|
||||
}
|
||||
|
||||
// Stop UIScene
|
||||
this.scene.stop('UIScene');
|
||||
|
||||
|
||||
537
tests/mycelium.test.ts
Normal file
537
tests/mycelium.test.ts
Normal file
@@ -0,0 +1,537 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import type { MyceliumGraphData, MetaState, RunState } from '../src/run/types';
|
||||
import { RunPhase } from '../src/run/types';
|
||||
import { createMetaState } from '../src/run/meta';
|
||||
import {
|
||||
createMyceliumGraph,
|
||||
depositKnowledge,
|
||||
getNode,
|
||||
getEdge,
|
||||
getNodesByType,
|
||||
getConnectedNodes,
|
||||
getGraphStats,
|
||||
} from '../src/mycelium/graph';
|
||||
import {
|
||||
extractMemoryFlashes,
|
||||
generateFlashText,
|
||||
} from '../src/mycelium/knowledge';
|
||||
import {
|
||||
createMycosisState,
|
||||
updateMycosis,
|
||||
getMycosisVisuals,
|
||||
} from '../src/mycelium/mycosis';
|
||||
import {
|
||||
getAvailableBonuses,
|
||||
purchaseBonus,
|
||||
canAffordBonus,
|
||||
} from '../src/mycelium/shop';
|
||||
import {
|
||||
spawnFungalNodes,
|
||||
} from '../src/mycelium/nodes';
|
||||
import type { MycosisState, MemoryFlash, FungalNodeInfo } from '../src/mycelium/types';
|
||||
import { MYCOSIS_CONFIG } from '../src/mycelium/types';
|
||||
|
||||
// ─── Test helpers ────────────────────────────────────────────────
|
||||
|
||||
function createTestRunState(runId: number = 1): RunState {
|
||||
return {
|
||||
runId,
|
||||
schoolId: 'alchemist',
|
||||
phase: RunPhase.Exploration,
|
||||
phaseTimer: 0,
|
||||
elapsed: 60000,
|
||||
escalation: 0,
|
||||
crisisActive: false,
|
||||
crisisResolved: false,
|
||||
discoveries: {
|
||||
elements: new Set(['Na', 'Cl', 'H', 'O']),
|
||||
reactions: new Set(['Na+Cl']),
|
||||
compounds: new Set(['NaCl']),
|
||||
creatures: new Set(['crystallid']),
|
||||
},
|
||||
alive: true,
|
||||
};
|
||||
}
|
||||
|
||||
function createTestMeta(): MetaState {
|
||||
const meta = createMetaState();
|
||||
meta.totalRuns = 2;
|
||||
meta.spores = 100;
|
||||
meta.mycelium = createMyceliumGraph();
|
||||
return meta;
|
||||
}
|
||||
|
||||
// ─── Mycelium Graph Tests ────────────────────────────────────────
|
||||
|
||||
describe('Mycelium Graph', () => {
|
||||
let graph: MyceliumGraphData;
|
||||
|
||||
beforeEach(() => {
|
||||
graph = createMyceliumGraph();
|
||||
});
|
||||
|
||||
it('should create an empty graph', () => {
|
||||
expect(graph.nodes).toHaveLength(0);
|
||||
expect(graph.edges).toHaveLength(0);
|
||||
expect(graph.totalDeposits).toBe(0);
|
||||
expect(graph.totalExtractions).toBe(0);
|
||||
});
|
||||
|
||||
it('should deposit element knowledge from a run', () => {
|
||||
const run = createTestRunState(1);
|
||||
const result = depositKnowledge(graph, run);
|
||||
|
||||
expect(result.newNodes).toBeGreaterThan(0);
|
||||
// Should have nodes for elements + reactions + compounds + creatures
|
||||
expect(graph.nodes.length).toBeGreaterThanOrEqual(4);
|
||||
expect(graph.totalDeposits).toBe(1);
|
||||
});
|
||||
|
||||
it('should create nodes for all discovery types', () => {
|
||||
const run = createTestRunState(1);
|
||||
depositKnowledge(graph, run);
|
||||
|
||||
// 4 elements (Na, Cl, H, O) + 1 reaction + 1 compound + 1 creature = 7
|
||||
expect(graph.nodes).toHaveLength(7);
|
||||
|
||||
const elementNodes = getNodesByType(graph, 'element');
|
||||
expect(elementNodes).toHaveLength(4);
|
||||
|
||||
const reactionNodes = getNodesByType(graph, 'reaction');
|
||||
expect(reactionNodes).toHaveLength(1);
|
||||
|
||||
const compoundNodes = getNodesByType(graph, 'compound');
|
||||
expect(compoundNodes).toHaveLength(1);
|
||||
|
||||
const creatureNodes = getNodesByType(graph, 'creature');
|
||||
expect(creatureNodes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should create edges between related discoveries', () => {
|
||||
const run = createTestRunState(1);
|
||||
depositKnowledge(graph, run);
|
||||
|
||||
// Elements that form a reaction should be connected to the reaction
|
||||
// Na + Cl → NaCl: edges Na→reaction, Cl→reaction, reaction→compound
|
||||
expect(graph.edges.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should strengthen existing nodes on re-deposit', () => {
|
||||
const run1 = createTestRunState(1);
|
||||
depositKnowledge(graph, run1);
|
||||
|
||||
const naNode = getNode(graph, 'element:Na');
|
||||
expect(naNode).toBeDefined();
|
||||
const initialStrength = naNode!.strength;
|
||||
|
||||
// Second deposit with same discoveries should strengthen
|
||||
const run2 = createTestRunState(2);
|
||||
run2.discoveries.elements = new Set(['Na']); // Only Na
|
||||
run2.discoveries.reactions = new Set();
|
||||
run2.discoveries.compounds = new Set();
|
||||
run2.discoveries.creatures = new Set();
|
||||
const result = depositKnowledge(graph, run2);
|
||||
|
||||
expect(result.strengthened).toBe(1);
|
||||
expect(result.newNodes).toBe(0);
|
||||
|
||||
const naNodeAfter = getNode(graph, 'element:Na');
|
||||
expect(naNodeAfter!.strength).toBeGreaterThan(initialStrength);
|
||||
});
|
||||
|
||||
it('should cap node strength at 1.0', () => {
|
||||
const run = createTestRunState(1);
|
||||
run.discoveries.elements = new Set(['Na']);
|
||||
run.discoveries.reactions = new Set();
|
||||
run.discoveries.compounds = new Set();
|
||||
run.discoveries.creatures = new Set();
|
||||
|
||||
// Deposit many times
|
||||
for (let i = 0; i < 20; i++) {
|
||||
depositKnowledge(graph, run);
|
||||
}
|
||||
|
||||
const naNode = getNode(graph, 'element:Na');
|
||||
expect(naNode!.strength).toBeLessThanOrEqual(1.0);
|
||||
});
|
||||
|
||||
it('should look up nodes by ID', () => {
|
||||
const run = createTestRunState(1);
|
||||
depositKnowledge(graph, run);
|
||||
|
||||
const node = getNode(graph, 'element:Na');
|
||||
expect(node).toBeDefined();
|
||||
expect(node!.type).toBe('element');
|
||||
expect(node!.knowledgeId).toBe('Na');
|
||||
|
||||
const missing = getNode(graph, 'element:Au');
|
||||
expect(missing).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should look up edges between nodes', () => {
|
||||
const run = createTestRunState(1);
|
||||
depositKnowledge(graph, run);
|
||||
|
||||
// There should be edges from Na and Cl to the Na+Cl reaction
|
||||
const naEdge = getEdge(graph, 'element:Na', 'reaction:Na+Cl');
|
||||
// Edge may exist in either direction — check both
|
||||
const naEdgeReverse = getEdge(graph, 'reaction:Na+Cl', 'element:Na');
|
||||
expect(naEdge ?? naEdgeReverse).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return connected nodes', () => {
|
||||
const run = createTestRunState(1);
|
||||
depositKnowledge(graph, run);
|
||||
|
||||
// Na should be connected to the Na+Cl reaction
|
||||
const connected = getConnectedNodes(graph, 'element:Na');
|
||||
expect(connected.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should report graph statistics', () => {
|
||||
const run = createTestRunState(1);
|
||||
depositKnowledge(graph, run);
|
||||
|
||||
const stats = getGraphStats(graph);
|
||||
expect(stats.nodeCount).toBe(7);
|
||||
expect(stats.edgeCount).toBeGreaterThan(0);
|
||||
expect(stats.totalDeposits).toBe(1);
|
||||
expect(stats.averageStrength).toBeGreaterThan(0);
|
||||
expect(stats.averageStrength).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Knowledge System Tests ──────────────────────────────────────
|
||||
|
||||
describe('Knowledge System', () => {
|
||||
let graph: MyceliumGraphData;
|
||||
|
||||
beforeEach(() => {
|
||||
graph = createMyceliumGraph();
|
||||
const run = createTestRunState(1);
|
||||
depositKnowledge(graph, run);
|
||||
});
|
||||
|
||||
it('should extract memory flashes from populated graph', () => {
|
||||
const flashes = extractMemoryFlashes(graph, 3);
|
||||
expect(flashes.length).toBeGreaterThan(0);
|
||||
expect(flashes.length).toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('should return empty flashes from empty graph', () => {
|
||||
const emptyGraph = createMyceliumGraph();
|
||||
const flashes = extractMemoryFlashes(emptyGraph, 3);
|
||||
expect(flashes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should generate flash text for element hints', () => {
|
||||
const flash = generateFlashText('element', 'Na');
|
||||
expect(flash.type).toBe('element_hint');
|
||||
expect(flash.text.length).toBeGreaterThan(0);
|
||||
expect(flash.textRu.length).toBeGreaterThan(0);
|
||||
// Should contain the element name somewhere
|
||||
expect(flash.text).toContain('Na');
|
||||
expect(flash.textRu).toContain('Na');
|
||||
});
|
||||
|
||||
it('should generate flash text for reaction hints', () => {
|
||||
const flash = generateFlashText('reaction', 'Na+Cl');
|
||||
expect(flash.type).toBe('reaction_hint');
|
||||
expect(flash.text.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should generate flash text for creature hints', () => {
|
||||
const flash = generateFlashText('creature', 'crystallid');
|
||||
expect(flash.type).toBe('creature_hint');
|
||||
expect(flash.text.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should increment extraction counter', () => {
|
||||
expect(graph.totalExtractions).toBe(0);
|
||||
extractMemoryFlashes(graph, 3);
|
||||
expect(graph.totalExtractions).toBe(1);
|
||||
});
|
||||
|
||||
it('should prefer stronger nodes for flashes', () => {
|
||||
// Strengthen Na by depositing multiple times
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const run = createTestRunState(i + 2);
|
||||
run.discoveries.elements = new Set(['Na']);
|
||||
run.discoveries.reactions = new Set();
|
||||
run.discoveries.compounds = new Set();
|
||||
run.discoveries.creatures = new Set();
|
||||
depositKnowledge(graph, run);
|
||||
}
|
||||
|
||||
// Extract many flashes — Na should appear frequently
|
||||
let naCount = 0;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const flashes = extractMemoryFlashes(graph, 3);
|
||||
for (const f of flashes) {
|
||||
if (f.text.includes('Na') || f.textRu.includes('Na')) naCount++;
|
||||
}
|
||||
}
|
||||
// Na should appear at least once in 10 rounds of 3 flashes
|
||||
expect(naCount).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Mycosis Tests ───────────────────────────────────────────────
|
||||
|
||||
describe('Mycosis', () => {
|
||||
let state: MycosisState;
|
||||
|
||||
beforeEach(() => {
|
||||
state = createMycosisState();
|
||||
});
|
||||
|
||||
it('should create initial mycosis state at zero', () => {
|
||||
expect(state.level).toBe(0);
|
||||
expect(state.exposure).toBe(0);
|
||||
expect(state.revealing).toBe(false);
|
||||
});
|
||||
|
||||
it('should increase level when near a fungal node', () => {
|
||||
updateMycosis(state, 1000, true); // 1 second near a node
|
||||
expect(state.level).toBeGreaterThan(0);
|
||||
expect(state.exposure).toBe(1000);
|
||||
});
|
||||
|
||||
it('should decrease level when away from nodes', () => {
|
||||
// Build up some mycosis
|
||||
updateMycosis(state, 5000, true);
|
||||
const peakLevel = state.level;
|
||||
|
||||
// Move away
|
||||
updateMycosis(state, 2000, false);
|
||||
expect(state.level).toBeLessThan(peakLevel);
|
||||
});
|
||||
|
||||
it('should not go below zero', () => {
|
||||
updateMycosis(state, 10000, false);
|
||||
expect(state.level).toBe(0);
|
||||
});
|
||||
|
||||
it('should not exceed max level', () => {
|
||||
updateMycosis(state, 100000, true); // Very long exposure
|
||||
expect(state.level).toBeLessThanOrEqual(MYCOSIS_CONFIG.maxLevel);
|
||||
});
|
||||
|
||||
it('should start revealing at threshold', () => {
|
||||
// Build up to reveal threshold
|
||||
const timeToThreshold = MYCOSIS_CONFIG.revealThreshold / MYCOSIS_CONFIG.buildRate * 1000;
|
||||
updateMycosis(state, timeToThreshold + 1000, true);
|
||||
expect(state.revealing).toBe(true);
|
||||
});
|
||||
|
||||
it('should stop revealing when level drops below threshold', () => {
|
||||
// Build up past threshold
|
||||
const timeToThreshold = MYCOSIS_CONFIG.revealThreshold / MYCOSIS_CONFIG.buildRate * 1000;
|
||||
updateMycosis(state, timeToThreshold + 2000, true);
|
||||
expect(state.revealing).toBe(true);
|
||||
|
||||
// Let it decay
|
||||
const decayTime = (state.level - MYCOSIS_CONFIG.revealThreshold + 0.1) / MYCOSIS_CONFIG.decayRate * 1000;
|
||||
updateMycosis(state, decayTime + 1000, false);
|
||||
expect(state.revealing).toBe(false);
|
||||
});
|
||||
|
||||
it('should return visual parameters based on level', () => {
|
||||
updateMycosis(state, 5000, true);
|
||||
|
||||
const visuals = getMycosisVisuals(state);
|
||||
expect(visuals.tintColor).toBe(MYCOSIS_CONFIG.tintColor);
|
||||
expect(visuals.tintAlpha).toBeGreaterThan(0);
|
||||
expect(visuals.tintAlpha).toBeLessThanOrEqual(MYCOSIS_CONFIG.maxTintAlpha);
|
||||
expect(visuals.distortionStrength).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should have zero visuals when mycosis is zero', () => {
|
||||
const visuals = getMycosisVisuals(state);
|
||||
expect(visuals.tintAlpha).toBe(0);
|
||||
expect(visuals.distortionStrength).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Spore Shop Tests ────────────────────────────────────────────
|
||||
|
||||
describe('Spore Shop', () => {
|
||||
it('should list available bonuses', () => {
|
||||
const bonuses = getAvailableBonuses();
|
||||
expect(bonuses.length).toBeGreaterThan(0);
|
||||
|
||||
// Each bonus should have required fields
|
||||
for (const bonus of bonuses) {
|
||||
expect(bonus.id).toBeTruthy();
|
||||
expect(bonus.nameRu).toBeTruthy();
|
||||
expect(bonus.cost).toBeGreaterThan(0);
|
||||
expect(bonus.effect).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should check affordability correctly', () => {
|
||||
const meta = createTestMeta();
|
||||
meta.spores = 15;
|
||||
|
||||
const bonuses = getAvailableBonuses();
|
||||
const cheapBonus = bonuses.find(b => b.cost <= 15);
|
||||
const expensiveBonus = bonuses.find(b => b.cost > 15);
|
||||
|
||||
if (cheapBonus) {
|
||||
expect(canAffordBonus(meta, cheapBonus.id)).toBe(true);
|
||||
}
|
||||
if (expensiveBonus) {
|
||||
expect(canAffordBonus(meta, expensiveBonus.id)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should deduct spores on purchase', () => {
|
||||
const meta = createTestMeta();
|
||||
const initialSpores = meta.spores;
|
||||
|
||||
const bonuses = getAvailableBonuses();
|
||||
const bonus = bonuses[0];
|
||||
const result = purchaseBonus(meta, bonus.id);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(meta.spores).toBe(initialSpores - bonus.cost);
|
||||
});
|
||||
|
||||
it('should return the purchased effect', () => {
|
||||
const meta = createTestMeta();
|
||||
const bonuses = getAvailableBonuses();
|
||||
const bonus = bonuses.find(b => b.effect.type === 'extra_health');
|
||||
expect(bonus).toBeDefined();
|
||||
|
||||
const result = purchaseBonus(meta, bonus!.id);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.type).toBe('extra_health');
|
||||
});
|
||||
|
||||
it('should fail to purchase with insufficient spores', () => {
|
||||
const meta = createTestMeta();
|
||||
meta.spores = 0;
|
||||
|
||||
const bonuses = getAvailableBonuses();
|
||||
const result = purchaseBonus(meta, bonuses[0].id);
|
||||
expect(result).toBeNull();
|
||||
expect(meta.spores).toBe(0); // unchanged
|
||||
});
|
||||
|
||||
it('should not allow non-repeatable bonuses to be purchased twice', () => {
|
||||
const meta = createTestMeta();
|
||||
meta.spores = 200;
|
||||
|
||||
const bonuses = getAvailableBonuses();
|
||||
const nonRepeatable = bonuses.find(b => !b.repeatable);
|
||||
if (!nonRepeatable) return; // skip if all are repeatable
|
||||
|
||||
const result1 = purchaseBonus(meta, nonRepeatable.id);
|
||||
expect(result1).not.toBeNull();
|
||||
|
||||
const result2 = purchaseBonus(meta, nonRepeatable.id);
|
||||
expect(result2).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Fungal Node Spawning Tests ──────────────────────────────────
|
||||
|
||||
describe('Fungal Node Spawning', () => {
|
||||
// Build a simple 20x20 grid with walkable ground
|
||||
const biome = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
nameRu: 'Тест',
|
||||
description: '',
|
||||
descriptionRu: '',
|
||||
tileSize: 16,
|
||||
mapWidth: 20,
|
||||
mapHeight: 20,
|
||||
tiles: [
|
||||
{ id: 0, name: 'ground', nameRu: 'земля', color: '#555', walkable: true, damage: 0, interactive: false, resource: false },
|
||||
{ id: 1, name: 'acid-deep', nameRu: 'кислота', color: '#0f0', walkable: false, damage: 5, interactive: false, resource: false },
|
||||
{ id: 2, name: 'scorched-earth', nameRu: 'выжженная', color: '#333', walkable: true, damage: 0, interactive: false, resource: false },
|
||||
],
|
||||
generation: {
|
||||
elevationScale: 0.1,
|
||||
detailScale: 0.2,
|
||||
elevationRules: [{ below: 0.5, tileId: 0 }],
|
||||
geyserThreshold: 0.9,
|
||||
mineralThreshold: 0.8,
|
||||
geyserOnTile: 0,
|
||||
mineralOnTiles: [0],
|
||||
},
|
||||
};
|
||||
|
||||
function createTestGrid(): number[][] {
|
||||
const grid: number[][] = [];
|
||||
for (let y = 0; y < 20; y++) {
|
||||
const row: number[] = [];
|
||||
for (let x = 0; x < 20; x++) {
|
||||
// Mostly ground, some acid
|
||||
row.push(x === 0 && y === 0 ? 1 : 0);
|
||||
}
|
||||
grid.push(row);
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
it('should spawn fungal nodes on walkable tiles', async () => {
|
||||
const { createWorld } = await import('bitecs');
|
||||
const world = createWorld();
|
||||
const grid = createTestGrid();
|
||||
|
||||
const nodeData = spawnFungalNodes(world, grid, biome, 42);
|
||||
expect(nodeData.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should not spawn on non-walkable tiles', async () => {
|
||||
const { createWorld } = await import('bitecs');
|
||||
const world = createWorld();
|
||||
|
||||
// Make grid entirely non-walkable
|
||||
const grid: number[][] = [];
|
||||
for (let y = 0; y < 20; y++) {
|
||||
grid.push(Array(20).fill(1)); // all acid
|
||||
}
|
||||
|
||||
const nodeData = spawnFungalNodes(world, grid, biome, 42);
|
||||
expect(nodeData.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should use deterministic placement based on seed', async () => {
|
||||
const { createWorld } = await import('bitecs');
|
||||
const grid = createTestGrid();
|
||||
|
||||
const world1 = createWorld();
|
||||
const data1 = spawnFungalNodes(world1, grid, biome, 42);
|
||||
|
||||
const world2 = createWorld();
|
||||
const data2 = spawnFungalNodes(world2, grid, biome, 42);
|
||||
|
||||
// Same seed → same positions
|
||||
const positions1 = [...data1.values()].map(d => `${d.tileX},${d.tileY}`).sort();
|
||||
const positions2 = [...data2.values()].map(d => `${d.tileX},${d.tileY}`).sort();
|
||||
expect(positions1).toEqual(positions2);
|
||||
});
|
||||
|
||||
it('should maintain minimum spacing between nodes', async () => {
|
||||
const { createWorld } = await import('bitecs');
|
||||
const world = createWorld();
|
||||
const grid = createTestGrid();
|
||||
|
||||
const nodeData = spawnFungalNodes(world, grid, biome, 42);
|
||||
const positions = [...nodeData.values()].map(d => ({ x: d.tileX, y: d.tileY }));
|
||||
|
||||
// No two nodes should be closer than minSpacing
|
||||
// (on a 20x20 grid with spacing 8, there can be only a few nodes)
|
||||
for (let i = 0; i < positions.length; i++) {
|
||||
for (let j = i + 1; j < positions.length; j++) {
|
||||
const dx = positions[i].x - positions[j].x;
|
||||
const dy = positions[i].y - positions[j].y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
expect(dist).toBeGreaterThanOrEqual(7); // slight tolerance
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user