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:
Денис Шкабатур
2026-02-12 15:47:03 +03:00
parent 35f8905921
commit 0d35cdcc73
17 changed files with 1866 additions and 22 deletions

75
src/data/mycelium.json Normal file
View 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": "...Мицелий помнит всех, кто шёл этими тропами..." }
]
}
}

View File

@@ -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
View 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
View 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
View 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 01 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
View 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
View 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 (01).
* 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
View 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
View 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 01 (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 01 */
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 (01) */
clarity: number;
}
// ─── Mycosis (visual distortion state) ──────────────────────────
/** Player's current mycosis (fungal influence) state */
export interface MycosisState {
/** Current mycosis intensity 01 */
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;
}

View File

@@ -20,6 +20,7 @@ export function createMetaState(): MetaState {
bestRunTime: 0,
bestRunDiscoveries: 0,
runHistory: [],
mycelium: { nodes: [], edges: [], totalDeposits: 0, totalExtractions: 0 },
};
}

View File

@@ -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,
};
}

View File

@@ -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 01 (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 01 */
weight: number;
}
export interface RunSummary {

View File

@@ -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,
});
});
}

View File

@@ -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');