Files
synthesis/src/scenes/GameScene.ts
Денис Шкабатур 3d4f710cb0 fix texture reuse on run cycle restart + expose debug kill
- Remove existing tilemap/minimap textures before recreating (prevents
  "Texture key already in use" error on second run)
- Expose __game and __debugKill for dev console testing

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 15:22:49 +03:00

645 lines
22 KiB
TypeScript

import Phaser from 'phaser';
import { createGameWorld, updateTime, type GameWorld } from '../ecs/world';
import { Health, Position, Creature, LifeCycle } from '../ecs/components';
import { movementSystem } from '../ecs/systems/movement';
import { healthSystem } from '../ecs/systems/health';
import { removeGameEntity } from '../ecs/factory';
import { PhaserBridge } from '../ecs/bridge';
import biomeDataArray from '../data/biomes.json';
import speciesDataArray from '../data/creatures.json';
import schoolsData from '../data/schools.json';
import type { BiomeData } from '../world/types';
import { generateWorld } from '../world/generator';
import { createWorldTilemap } from '../world/tilemap';
import { setupPlayerCamera } from '../world/camera';
import { Minimap } from '../world/minimap';
import { playerInputSystem } from '../player/input';
import { tileCollisionSystem, buildWalkableSet } from '../player/collision';
import { findSpawnPosition } from '../player/spawn';
import { createPlayerEntity } from '../player/factory';
import { Inventory } from '../player/inventory';
import { interactionSystem, type ResourceInfo } from '../player/interaction';
import { spawnResources } from '../world/resources';
import {
launchProjectile,
projectileSystem,
type ProjectileData,
} from '../player/projectile';
import { QuickSlots } from '../player/quickslots';
import type { InputState } from '../player/types';
import type { SpeciesData, CreatureInfo } from '../creatures/types';
import { SpeciesRegistry } from '../creatures/types';
import { aiSystem } from '../creatures/ai';
import { metabolismSystem, clearMetabolismTracking } from '../creatures/metabolism';
import { lifeCycleSystem } from '../creatures/lifecycle';
import {
countPopulations,
spawnInitialCreatures,
reproduce,
} from '../creatures/population';
import {
creatureProjectileSystem,
getObservableCreatures,
creatureAttackPlayerSystem,
} from '../creatures/interaction';
import { query } from 'bitecs';
// Run cycle imports
import type { MetaState, SchoolData, RunState } from '../run/types';
import { RunPhase, RUN_PHASE_NAMES_RU, PHASE_DURATIONS } from '../run/types';
import { createRunState, advancePhase, updateEscalation, recordDiscovery } from '../run/state';
import {
createCrisisState,
applyCrisisDamage,
attemptNeutralize,
CHEMICAL_PLAGUE,
type CrisisState,
} from '../run/crisis';
export class GameScene extends Phaser.Scene {
private gameWorld!: GameWorld;
private bridge!: PhaserBridge;
private minimap!: Minimap;
private statsText!: Phaser.GameObjects.Text;
private worldSeed!: number;
// Player state
private playerEid!: number;
private inventory!: Inventory;
private walkableSet!: Set<number>;
private worldGrid!: number[][];
private tileSize!: number;
private resourceData!: Map<number, ResourceInfo>;
private projectileData!: Map<number, ProjectileData>;
private quickSlots!: QuickSlots;
private keys!: {
W: Phaser.Input.Keyboard.Key;
A: Phaser.Input.Keyboard.Key;
S: Phaser.Input.Keyboard.Key;
D: Phaser.Input.Keyboard.Key;
E: Phaser.Input.Keyboard.Key;
F: Phaser.Input.Keyboard.Key;
ONE: Phaser.Input.Keyboard.Key;
TWO: Phaser.Input.Keyboard.Key;
THREE: Phaser.Input.Keyboard.Key;
FOUR: Phaser.Input.Keyboard.Key;
};
// Creature state
private speciesRegistry!: SpeciesRegistry;
private speciesLookup!: Map<number, SpeciesData>;
private creatureData!: Map<number, CreatureInfo>;
// Interaction feedback
private interactionText!: Phaser.GameObjects.Text;
private interactionTimer = 0;
private wasEDown = false;
private wasFDown = false;
// Run cycle state
private meta!: MetaState;
private runState!: RunState;
private crisisState: CrisisState | null = null;
private phaseText!: Phaser.GameObjects.Text;
private playerDead = false;
private crisisOverlay!: Phaser.GameObjects.Rectangle;
constructor() {
super({ key: 'GameScene' });
}
init(data: { meta: MetaState; schoolId: string; runId: number }): void {
this.meta = data.meta;
this.runState = createRunState(data.runId, data.schoolId);
this.crisisState = null;
this.playerDead = false;
}
create(): void {
// 1. Initialize ECS
this.gameWorld = createGameWorld();
this.bridge = new PhaserBridge(this);
this.projectileData = new Map();
// 2. Generate world
const biome = biomeDataArray[0] as BiomeData;
this.worldSeed = Date.now() % 1000000;
const worldData = generateWorld(biome, this.worldSeed);
// 3. Create tilemap
createWorldTilemap(this, worldData);
// 4. Build walkable set + store world data for collision
this.walkableSet = buildWalkableSet(biome.tiles);
this.worldGrid = worldData.grid;
this.tileSize = biome.tileSize;
// 5. Spawn resource entities (mineral veins, geysers)
this.resourceData = spawnResources(
this.gameWorld.world, worldData.grid, biome, this.worldSeed,
);
// 6. Initialize creature systems
const allSpecies = speciesDataArray as SpeciesData[];
this.speciesRegistry = new SpeciesRegistry(allSpecies);
this.speciesLookup = new Map<number, SpeciesData>();
for (const s of allSpecies) {
this.speciesLookup.set(s.speciesId, s);
}
// 7. Spawn creatures across the map
this.creatureData = spawnInitialCreatures(
this.gameWorld.world, worldData.grid, biome, this.worldSeed, allSpecies,
);
// 8. Create player at spawn position + inventory with starting kit
const spawn = findSpawnPosition(worldData.grid, biome.tileSize, this.walkableSet);
const spawnX = spawn?.x ?? (biome.mapWidth * biome.tileSize) / 2;
const spawnY = spawn?.y ?? (biome.mapHeight * biome.tileSize) / 2;
this.playerEid = createPlayerEntity(this.gameWorld.world, spawnX, spawnY);
this.inventory = new Inventory(500, 20);
this.quickSlots = new QuickSlots();
// Give starting elements from chosen school
this.giveStartingKit();
// 9. Camera — follow player, zoom via scroll wheel
const worldPixelW = biome.mapWidth * biome.tileSize;
const worldPixelH = biome.mapHeight * biome.tileSize;
setupPlayerCamera(this, worldPixelW, worldPixelH);
// Sync bridge to create sprites, then attach camera follow to player
this.bridge.sync(this.gameWorld.world);
const playerSprite = this.bridge.getSprite(this.playerEid);
if (playerSprite) {
playerSprite.setDepth(10);
this.cameras.main.startFollow(playerSprite, true, 0.1, 0.1);
}
// 8. Keyboard input
const keyboard = this.input.keyboard;
if (!keyboard) throw new Error('Keyboard plugin not available');
this.keys = {
W: keyboard.addKey('W'),
A: keyboard.addKey('A'),
S: keyboard.addKey('S'),
D: keyboard.addKey('D'),
E: keyboard.addKey('E'),
F: keyboard.addKey('F'),
ONE: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ONE),
TWO: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.TWO),
THREE: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.THREE),
FOUR: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.FOUR),
};
// 9. Minimap
this.minimap = new Minimap(this, worldData);
// 10. UI overlay
this.statsText = this.add.text(10, 10, '', {
fontSize: '12px',
color: '#00ff88',
fontFamily: 'monospace',
backgroundColor: '#000000aa',
padding: { x: 4, y: 2 },
});
this.statsText.setScrollFactor(0);
this.statsText.setDepth(100);
// Interaction feedback text (center-bottom of screen)
this.interactionText = this.add.text(
this.cameras.main.width / 2, this.cameras.main.height - 40, '', {
fontSize: '14px',
color: '#ffdd44',
fontFamily: 'monospace',
backgroundColor: '#000000cc',
padding: { x: 6, y: 3 },
},
);
this.interactionText.setScrollFactor(0);
this.interactionText.setOrigin(0.5);
this.interactionText.setDepth(100);
this.interactionText.setAlpha(0);
// Phase indicator (top-center)
this.phaseText = this.add.text(
this.cameras.main.width / 2, 12, '', {
fontSize: '12px',
color: '#00ff88',
fontFamily: 'monospace',
backgroundColor: '#000000aa',
padding: { x: 6, y: 2 },
},
);
this.phaseText.setScrollFactor(0);
this.phaseText.setOrigin(0.5, 0);
this.phaseText.setDepth(100);
// Crisis overlay (full-screen tinted rectangle, hidden by default)
this.crisisOverlay = this.add.rectangle(
this.cameras.main.width / 2, this.cameras.main.height / 2,
this.cameras.main.width, this.cameras.main.height,
0x88ff88, 0,
);
this.crisisOverlay.setScrollFactor(0);
this.crisisOverlay.setDepth(90);
// 11. Launch UIScene overlay
this.scene.launch('UIScene');
// Transition from Awakening to Exploration after a moment
this.time.delayedCall(500, () => {
advancePhase(this.runState); // Awakening → Exploration
});
// Debug: expose kill method for testing death cycle
(this as unknown as Record<string, unknown>).__debugKill = () => {
Health.current[this.playerEid] = 0;
};
}
/** Give the player their school's starting elements */
private giveStartingKit(): void {
const schools = schoolsData as SchoolData[];
const school = schools.find(s => s.id === this.runState.schoolId);
if (!school) return;
for (const symbol of school.startingElements) {
const qty = school.startingQuantities[symbol] ?? 1;
for (let i = 0; i < qty; i++) {
this.inventory.addItem(symbol);
}
this.quickSlots.autoAssign(symbol);
// Record discovery of starting elements
recordDiscovery(this.runState, 'element', symbol);
}
}
update(_time: number, delta: number): void {
// Skip updates if death transition is in progress
if (this.playerDead) return;
// 1. Update world time
updateTime(this.gameWorld, delta);
// 1a. Update run state timers
this.runState.elapsed += delta;
this.runState.phaseTimer += delta;
this.updateRunPhase(delta);
// 2. Read keyboard → InputState
const input: InputState = {
moveX: (this.keys.D.isDown ? 1 : 0) - (this.keys.A.isDown ? 1 : 0),
moveY: (this.keys.S.isDown ? 1 : 0) - (this.keys.W.isDown ? 1 : 0),
interact: this.keys.E.isDown,
};
// 3. Player input → velocity
playerInputSystem(this.gameWorld.world, input);
// 4. Movement (all entities including projectiles)
movementSystem(this.gameWorld.world, delta);
// 5. Tile collision (player only)
tileCollisionSystem(
this.gameWorld.world,
delta,
this.worldGrid,
this.tileSize,
this.walkableSet,
);
// 6. Projectile system (lifetime + tile collision)
projectileSystem(
this.gameWorld.world,
delta,
this.worldGrid,
this.tileSize,
this.walkableSet,
this.projectileData,
);
// 7. Resource interaction (E key, debounced)
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);
}
}
this.showInteractionFeedback(interaction.type, interaction.itemId);
}
// 8. Quick slot selection (1-4 keys)
if (this.keys.ONE.isDown) this.quickSlots.setActive(0);
if (this.keys.TWO.isDown) this.quickSlots.setActive(1);
if (this.keys.THREE.isDown) this.quickSlots.setActive(2);
if (this.keys.FOUR.isDown) this.quickSlots.setActive(3);
// 9. Throw projectile (F key, debounced) — uses active quick slot
const isFDown = this.keys.F.isDown;
const justPressedF = isFDown && !this.wasFDown;
this.wasFDown = isFDown;
if (justPressedF) {
this.tryLaunchProjectile();
}
// 9a. Creature AI — adjust aggression based on escalation
aiSystem(
this.gameWorld.world, delta,
this.speciesLookup, this.gameWorld.time.tick,
);
// 9b. Creature metabolism (feeding, energy drain)
metabolismSystem(
this.gameWorld.world, delta,
this.resourceData, this.gameWorld.time.elapsed,
);
// 9c. Creature life cycle (aging, stage transitions)
const lcEvents = lifeCycleSystem(
this.gameWorld.world, delta, this.speciesLookup,
);
// 9d. Handle reproduction events
const populations = countPopulations(this.gameWorld.world);
for (const event of lcEvents) {
if (event.type === 'ready_to_reproduce') {
const species = this.speciesLookup.get(event.speciesId);
if (species) {
const currentPop = populations.get(event.speciesId) ?? 0;
reproduce(
this.gameWorld.world, event.eid, species,
currentPop, this.creatureData,
);
}
}
}
// 9e. Projectile-creature collision
creatureProjectileSystem(
this.gameWorld.world, this.projectileData, this.speciesLookup,
);
// 9f. Creature attacks on player
creatureAttackPlayerSystem(this.gameWorld.world, this.speciesLookup);
// 9g. Crisis damage (if active)
if (this.crisisState?.active) {
applyCrisisDamage(this.crisisState, delta);
// Crisis damages player slowly
if (this.crisisState.progress > 0.3) {
const crisisDmg = this.crisisState.progress * 0.5 * (delta / 1000);
Health.current[this.playerEid] = Math.max(
0,
(Health.current[this.playerEid] ?? 0) - crisisDmg,
);
}
}
// 10. Health / death
const dead = healthSystem(this.gameWorld.world);
let playerDied = false;
for (const eid of dead) {
if (eid === this.playerEid) {
playerDied = true;
continue; // Don't remove player entity yet
}
// Clean up creature tracking
if (this.creatureData.has(eid)) {
clearMetabolismTracking(eid);
this.creatureData.delete(eid);
}
removeGameEntity(this.gameWorld.world, eid);
}
// Handle player death → transition to DeathScene
if (playerDied) {
this.onPlayerDeath();
return;
}
// 10. Render sync
this.bridge.sync(this.gameWorld.world);
// 11. Minimap viewport
this.minimap.update(this.cameras.main);
// 12. Fade interaction text
if (this.interactionTimer > 0) {
this.interactionTimer -= delta;
if (this.interactionTimer <= 0) {
this.interactionText.setAlpha(0);
}
}
// 13. Push shared state to registry for UIScene
this.registry.set('health', Health.current[this.playerEid] ?? 100);
this.registry.set('healthMax', Health.max[this.playerEid] ?? 100);
this.registry.set('quickSlots', this.quickSlots.getAll());
this.registry.set('activeSlot', this.quickSlots.activeIndex);
this.registry.set('invWeight', this.inventory.getTotalWeight());
this.registry.set('invMaxWeight', this.inventory.maxWeight);
this.registry.set('invSlots', this.inventory.slotCount);
// Build counts map for UIScene
const counts = new Map<string, number>();
for (const item of this.inventory.getItems()) {
counts.set(item.id, item.count);
}
this.registry.set('invCounts', counts);
// 15. Creature observation for UIScene
const nearbyCreatures = getObservableCreatures(this.gameWorld.world);
if (nearbyCreatures.length > 0) {
const closest = nearbyCreatures[0];
const species = this.speciesLookup.get(closest.speciesId);
this.registry.set('observedCreature', {
name: species?.name ?? 'Unknown',
healthPercent: closest.healthPercent,
energyPercent: closest.energyPercent,
stage: closest.stage,
});
// Record creature observation as discovery
if (species) {
recordDiscovery(this.runState, 'creature', species.id);
}
} else {
this.registry.set('observedCreature', null);
}
// 16. Population counts for debug
const popCounts = countPopulations(this.gameWorld.world);
this.registry.set('creaturePopulations', popCounts);
// 17. Debug stats overlay + phase indicator
const fps = delta > 0 ? Math.round(1000 / delta) : 0;
const px = Math.round(Position.x[this.playerEid]);
const py = Math.round(Position.y[this.playerEid]);
const creatureCount = [...popCounts.values()].reduce((a, b) => a + b, 0);
this.statsText.setText(
`seed: ${this.worldSeed} | ${fps} fps | pos: ${px},${py} | creatures: ${creatureCount}`,
);
// Update phase indicator
const phaseName = RUN_PHASE_NAMES_RU[this.runState.phase];
const escalationPct = Math.round(this.runState.escalation * 100);
const crisisInfo = this.crisisState?.active ? ` | ЧУМА: ${Math.round(this.crisisState.progress * 100)}%` : '';
this.phaseText.setText(`${phaseName} | Эскалация: ${escalationPct}%${crisisInfo}`);
// Color phase text based on danger
if (this.runState.phase >= RunPhase.Crisis) {
this.phaseText.setColor('#ff4444');
} else if (this.runState.phase >= RunPhase.Escalation) {
this.phaseText.setColor('#ffaa00');
} else {
this.phaseText.setColor('#00ff88');
}
}
/** Manage run phase transitions and escalation */
private updateRunPhase(delta: number): void {
const phase = this.runState.phase;
const timer = this.runState.phaseTimer;
const duration = PHASE_DURATIONS[phase];
// Update escalation
updateEscalation(this.runState, delta);
// Auto-advance timed phases
if (duration > 0 && timer >= duration) {
if (phase === RunPhase.Exploration) {
advancePhase(this.runState); // → Escalation
} else if (phase === RunPhase.Escalation) {
advancePhase(this.runState); // → Crisis
this.triggerCrisis();
} else if (phase === RunPhase.Resolution) {
// Run complete — could transition to a victory scene
// For now, just keep playing
}
}
// Trigger crisis when escalation hits threshold (even before phase ends)
if (
phase === RunPhase.Escalation &&
!this.crisisState &&
this.runState.escalation >= CHEMICAL_PLAGUE.triggerThreshold
) {
advancePhase(this.runState); // → Crisis
this.triggerCrisis();
}
}
/** Activate the Chemical Plague crisis */
private triggerCrisis(): void {
this.crisisState = createCrisisState(CHEMICAL_PLAGUE);
this.runState.crisisActive = true;
this.showInteractionFeedback('collected', '⚠ ХИМИЧЕСКАЯ ЧУМА! Создай CaO для нейтрализации!');
// Tint the world slightly toxic via overlay
this.crisisOverlay.setFillStyle(0x88ff88, 0.08);
}
/** Handle player death — start death sequence */
private onPlayerDeath(): void {
this.playerDead = true;
this.runState.alive = false;
// Stop UIScene
this.scene.stop('UIScene');
// Slow-motion effect
this.cameras.main.fadeOut(2000, 0, 0, 0);
this.cameras.main.once('camerafadeoutcomplete', () => {
this.scene.start('DeathScene', {
meta: this.meta,
runState: this.runState,
});
});
}
/** Try to launch a projectile from active quick slot toward mouse */
private tryLaunchProjectile(): void {
const itemId = this.quickSlots.getActive();
if (!itemId || !this.inventory.hasItem(itemId)) {
this.showInteractionFeedback('nothing_nearby');
return;
}
const removed = this.inventory.removeItem(itemId, 1);
if (removed === 0) return;
// Get mouse world position for direction
const pointer = this.input.activePointer;
const worldPoint = this.cameras.main.getWorldPoint(pointer.x, pointer.y);
const px = Position.x[this.playerEid];
const py = Position.y[this.playerEid];
launchProjectile(
this.gameWorld.world,
this.projectileData,
px, py,
worldPoint.x, worldPoint.y,
itemId,
);
// Check if this compound neutralizes the crisis
if (this.crisisState?.active && itemId === CHEMICAL_PLAGUE.neutralizer) {
attemptNeutralize(this.crisisState, itemId, 1);
if (this.crisisState.resolved) {
this.runState.crisisResolved = true;
this.runState.crisisActive = false;
this.crisisOverlay.setFillStyle(0x88ff88, 0);
this.showInteractionFeedback('collected', '✓ Чума нейтрализована!');
advancePhase(this.runState); // Crisis → Resolution
} else {
this.showInteractionFeedback('collected', `Нейтрализация: ${this.crisisState.neutralizeApplied}/${CHEMICAL_PLAGUE.neutralizeAmount}`);
}
}
// Clear quick slot if inventory is now empty for this item
if (!this.inventory.hasItem(itemId)) {
const slotIdx = this.quickSlots.getAll().indexOf(itemId);
if (slotIdx >= 0) this.quickSlots.assign(slotIdx, null);
}
if (!this.crisisState?.active || itemId !== CHEMICAL_PLAGUE.neutralizer) {
this.showInteractionFeedback('collected', `Threw ${itemId}`);
}
}
private showInteractionFeedback(type: string, itemId?: string): void {
let msg = '';
switch (type) {
case 'collected':
msg = itemId?.startsWith('Threw') || itemId?.startsWith('⚠') || itemId?.startsWith('✓') || itemId?.startsWith('Нейтрализация')
? itemId
: `+1 ${itemId ?? ''}`;
break;
case 'depleted':
msg = `+1 ${itemId ?? ''} (depleted)`;
break;
case 'inventory_full':
msg = `Inventory full! Can't pick up ${itemId ?? ''}`;
break;
case 'nothing_nearby':
msg = 'Nothing to throw / interact with';
break;
}
this.interactionText.setText(msg);
this.interactionText.setAlpha(1);
this.interactionTimer = 1500;
}
}