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

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