- 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>
645 lines
22 KiB
TypeScript
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;
|
|
}
|
|
}
|