Law of Trace: "nothing disappears without a trace." - WorldTrace ECS component (traceType, sourceRunId, glowPhase) - Death site markers placed at exact death positions (dark red) - Discovery site markers for runs with 3+ discoveries (blue-gray) - Deterministic position derivation from world seed - Traces from current AND previous great cycle included - Biome filtering, map bounds clamping, glow animation - 13 new tests (562 total), all passing Co-authored-by: Cursor <cursoragent@cursor.com>
195 lines
5.7 KiB
TypeScript
195 lines
5.7 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|
|
}
|