/** * World Trace Placement — places ruins and markers from past runs * * GDD (Law of Trace): "Nothing disappears without a trace; * each cycle leaves an imprint in the next." * * When generating a new world, we check for traces from past runs * in the same biome (from the current and previous great cycle). * Death sites become ruin markers; discovery sites become faded experiment traces. */ import { addEntity, addComponent } from 'bitecs'; import type { World } from '../ecs/world'; import { Position, SpriteRef, WorldTrace, TraceType } from '../ecs/components'; import type { BiomeData } from './types'; import type { RunTrace, GreatCycleState } from '../run/types'; import { getTracesForBiome, getDeathTraces } from '../run/cycle'; /** String data for a world trace entity (not stored in ECS — numeric only there) */ export interface WorldTraceInfo { /** Entity ID */ eid: number; /** Type of trace */ traceType: 'death_site' | 'discovery_site'; /** Run that left this trace */ sourceRunId: number; /** School used in that run */ schoolId: string; /** Key elements discovered (for discovery sites) */ keyElements: string[]; /** Tile position */ tileX: number; tileY: number; } /** Visual config for trace markers */ const TRACE_CONFIG = { death_site: { color: 0x884444, // dark red — blood/decay radius: 6, interactRange: 40, glowColor: 0xaa3333, // red glow }, discovery_site: { color: 0x446688, // blue-gray — knowledge radius: 5, interactRange: 36, glowColor: 0x4488aa, // blue glow }, } as const; /** * Spawn world trace entities from past run traces. * * For each trace from the current/previous cycle in this biome: * - Death sites: place a ruin marker at the death position * - Remaining traces: place discovery markers at a position derived from * the run's world seed (since we don't store exact discovery locations) * * @returns Array of WorldTraceInfo for display/interaction */ export function spawnWorldTraces( world: World, cycleState: GreatCycleState, biomeId: string, biome: BiomeData, ): WorldTraceInfo[] { const traces = getTracesForBiome(cycleState, biomeId); if (traces.length === 0) return []; const result: WorldTraceInfo[] = []; const tileSize = biome.tileSize; const mapW = biome.mapWidth; const mapH = biome.mapHeight; // Place death site markers const deathTraces = getDeathTraces(traces); for (const trace of deathTraces) { const pos = trace.deathPosition; if (!pos) continue; // Clamp to map bounds const tx = Math.max(0, Math.min(pos.tileX, mapW - 1)); const ty = Math.max(0, Math.min(pos.tileY, mapH - 1)); const eid = createTraceEntity( world, tx, ty, tileSize, TraceType.DeathSite, trace.runId, TRACE_CONFIG.death_site, ); result.push({ eid, traceType: 'death_site', sourceRunId: trace.runId, schoolId: trace.schoolId, keyElements: trace.keyElements, tileX: tx, tileY: ty, }); } // Place discovery markers for traces with significant discoveries // (Only for traces without death sites, to avoid double-marking) const deathRunIds = new Set(deathTraces.map(t => t.runId)); const discoveryTraces = traces.filter( t => !deathRunIds.has(t.runId) && t.discoveryCount >= 3, ); for (const trace of discoveryTraces) { // Derive a position from the run's seed (deterministic but varied) const tx = deriveTracePosition(trace.worldSeed, 0, mapW); const ty = deriveTracePosition(trace.worldSeed, 1, mapH); const eid = createTraceEntity( world, tx, ty, tileSize, TraceType.DiscoverySite, trace.runId, TRACE_CONFIG.discovery_site, ); result.push({ eid, traceType: 'discovery_site', sourceRunId: trace.runId, schoolId: trace.schoolId, keyElements: trace.keyElements, tileX: tx, tileY: ty, }); } return result; } /** Create a single trace entity with position, sprite, and trace component */ function createTraceEntity( world: World, tileX: number, tileY: number, tileSize: number, traceType: number, sourceRunId: number, config: { color: number; radius: number; interactRange: number }, ): number { const eid = addEntity(world); addComponent(world, eid, Position); Position.x[eid] = tileX * tileSize + tileSize / 2; Position.y[eid] = tileY * tileSize + tileSize / 2; addComponent(world, eid, SpriteRef); SpriteRef.color[eid] = config.color; SpriteRef.radius[eid] = config.radius; addComponent(world, eid, WorldTrace); WorldTrace.traceType[eid] = traceType; WorldTrace.sourceRunId[eid] = sourceRunId; WorldTrace.glowPhase[eid] = Math.random() * Math.PI * 2; WorldTrace.interactRange[eid] = config.interactRange; return eid; } /** * Derive a deterministic tile position from a seed. * Ensures traces from the same run appear in consistent locations. */ function deriveTracePosition(seed: number, axis: number, mapSize: number): number { // Mulberry32-style hash let t = (seed + axis * 0x6d2b79f5 + 0x9e3779b9) | 0; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); const normalized = ((t ^ (t >>> 14)) >>> 0) / 4294967296; // Keep away from edges (10% margin) const margin = Math.floor(mapSize * 0.1); return margin + Math.floor(normalized * (mapSize - 2 * margin)); } /** * Update trace glow animation (pulsing effect). * Called in the game update loop. */ export function updateTraceGlow( traceInfos: WorldTraceInfo[], delta: number, ): void { const pulseSpeed = 1.2; // radians per second const deltaSec = delta / 1000; for (const info of traceInfos) { WorldTrace.glowPhase[info.eid] += pulseSpeed * deltaSec; } }