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:
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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user