clone

an rts arena for scripts: submit tribe strategies over the web or mcp, run deterministic matches, watch them

feat: v0.4.0 map effects — the event log gets a visual twin

Dami committed 51b177b
The world map now animates the story it was already logging. A new
effects layer (web/src/render/effects.ts) derives transient visuals from
the frame event channels: battles pop crossed swords with an expanding
winner-tinted ring and per-side loss floats; captured settlements burn
under the captor's double shockwave; foundings bloom a banner; raids and
caravan hits float their losses; buildings pop their own glyph when they
come online and burn when they fall; starvation counts its dead; threats
arrive with a pop (demon gates through a portal) and fade when cleared.

Grammar: five shared primitives (ring, stepped glyph pop, fade, flame
flicker, float text) composed per event kind. Effects age by wall clock
so they read at any sim speed, coalesce per tile so max speed pulses
instead of strobing, and evict texture before drama at the 24-effect
cap. Pure presentation: POV-aware ingest reads the engine's fog-filtered
channels, never engine state; idle cost stays zero via the existing
dirty-flag paint loop.

Protocol/engine: building_complete, building_destroyed, raid, and
starvation events now carry x/y (all emission sites + dev-mock). Events
stay out of stateHash; note viewFor now delivers these to any tribe with
vision of the location — scripts can see raids they watch happen.

Dev: window.__fx console hook (dev builds) drives synthetic effects for
tuning; web tsconfig gains vite/client types for the DEV gate.
commit
51b177b957b78737fcf638cdbf0e04ac8e88484e
parent
afc57cb6d8d38890d27cf25bb215bad2de9c7da4
author
Dami <dami.etoile@gmail.com>
7 files changed, +362 -11
Mpackages/engine/src/world.ts14+
Mpackages/protocol/src/game.ts8+-
Mweb/src/dev-mock.ts8+-
Mweb/src/pages/viewer.ts19++-
Aweb/src/render/effects.ts317++++++++++++++++++++++++++++++
Mweb/src/render/map-view.ts6+-
Mweb/tsconfig.json1+
M packages/engine/src/world.ts +14 -0
M packages/protocol/src/game.ts +4 -4
@@ -381,8 +381,8 @@ export type SimEvent =
381381 | { t: 'say'; tick: number; tribe: number; text: string }382382 | { t: 'settlement_founded'; tick: number; tribe: number; settlementId: number; x: number; y: number }383383 | { t: 'settlement_captured'; tick: number; tribe: number; by: number; settlementId: number; x: number; y: number }384- | { t: 'building_complete'; tick: number; tribe: number; building: Building; settlementId: number }385- | { t: 'building_destroyed'; tick: number; tribe: number; building: Building; settlementId: number; by: number | null }384+ | { t: 'building_complete'; tick: number; tribe: number; building: Building; settlementId: number; x: number; y: number }385+ | { t: 'building_destroyed'; tick: number; tribe: number; building: Building; settlementId: number; by: number | null; x: number; y: number }386386 | {387387 t: 'battle';388388 tick: number;
@@ -396,9 +396,9 @@ export type SimEvent =
396396 /** Winning side's tribe id; null = draw/withdraw or threat won. */397397 winner: number | null;398398 }399- | { t: 'raid'; tick: number; tribe: number; by: number; settlementId: number; loot: Partial<Record<Resource, number>> }399+ | { t: 'raid'; tick: number; tribe: number; by: number; settlementId: number; loot: Partial<Record<Resource, number>>; x: number; y: number }400400 | { t: 'caravan_raided'; tick: number; tribe: number; by: number; x: number; y: number; resource: Resource; amount: number }401- | { t: 'starvation'; tick: number; tribe: number; settlementId: number; deaths: number }401+ | { t: 'starvation'; tick: number; tribe: number; settlementId: number; deaths: number; x: number; y: number }402402 | { t: 'threat_spawned'; tick: number; kind: ThreatKind; x: number; y: number }403403 | { t: 'threat_cleared'; tick: number; tribe: number; kind: ThreatKind; x: number; y: number }404404 | { t: 'tech_unlocked'; tick: number; tribe: number; tech: Tech }
M web/src/dev-mock.ts +5 -3
@@ -404,7 +404,7 @@ class MockWorld implements EngineWorld {
404404 t.starvationDeaths += starved;405405 t.deaths += starved;406406 this.emitEvent(407- { t: 'starvation', tick: this.tick, tribe: t.id, settlementId: t.settlements[0]!.id, deaths: starved },407+ { t: 'starvation', tick: this.tick, tribe: t.id, settlementId: t.settlements[0]!.id, deaths: starved, x: t.settlements[0]!.x, y: t.settlements[0]!.y },408408 [t.id],409409 );410410 }
@@ -478,7 +478,7 @@ class MockWorld implements EngineWorld {
478478 const which = (['house', 'granary', 'smithy', 'market', 'walls'] as const)[ri(rng, 5)]!;479479 if (which === 'walls') s0.walls++;480480 this.emitEvent(481- { t: 'building_complete', tick: this.tick, tribe: t.id, building: which, settlementId: s0.id },481+ { t: 'building_complete', tick: this.tick, tribe: t.id, building: which, settlementId: s0.id, x: s0.x, y: s0.y },482482 [t.id],483483 );484484 }
@@ -578,7 +578,7 @@ class MockWorld implements EngineWorld {
578578 o.stores.coin -= coin;579579 t.stores.grain += grain;580580 t.stores.coin += coin;581- this.emitEvent({ t: 'raid', tick: this.tick, tribe: o.id, by: t.id, settlementId: target.id, loot: { grain, coin } });581+ this.emitEvent({ t: 'raid', tick: this.tick, tribe: o.id, by: t.id, settlementId: target.id, loot: { grain, coin }, x: target.x, y: target.y });582582 }583583 }584584 }
@@ -683,6 +683,8 @@ class MockWorld implements EngineWorld {
683683 by: winT.id,684684 settlementId: target.id,685685 loot: { grain, coin },686+ x: target.x,687+ y: target.y,686688 });687689 }688690 }
M web/src/pages/viewer.ts +16 -3
@@ -23,6 +23,7 @@ import { log } from '../log';
2323 import type { AppCtx } from '../main';2424 import type { MapFrame, LogLine } from '../sim/frames';2525 import { SimRunner, type Speed } from '../sim/runner';26+import { createEffects } from '../render/effects';2627 import { createMapView } from '../render/map-view';2728 import { loadUrizen, TILE, type UrizenAtlas } from '../render/urizen';2829 import { createDashboard } from './dashboard';
@@ -170,7 +171,11 @@ function buildViewer(
170171 );171172 const worldToolbar = h('div', { class: 'row world-toolbar' }, povSel, fitBtn);172173 const mapHost = h('div', { class: 'map-host' });173- const mapView = createMapView(mapHost, { mapSize, names });174+ const effects = createEffects();175+ // Dev console hook: drive effect visuals by hand (e.g. __fx.ingest({pov:-1,176+ // eventsByTribe:{}, world:[...]}) with synthetic events) while tuning.177+ if (import.meta.env.DEV) (window as unknown as { __fx?: unknown }).__fx = effects;178+ const mapView = createMapView(mapHost, { mapSize, names, effects });174179 175180 // replay invitation — shown in instant mode until the runner boots176181 const inviteGlyphs = h('div', { class: 'glyphs' });
@@ -286,7 +291,10 @@ function buildViewer(
286291 new SimRunner(bundle, mock, {287292 onFrame(frame) {288293 latestFrame = frame;289- if (frame.hasMap) mapDirty = true;294+ if (frame.hasMap) {295+ mapDirty = true;296+ effects.ingest(frame); // map effects only spawn while watching297+ }290298 // Append here (not in the paint loop): frames can arrive faster than291299 // paints, and each frame's batch must reach the feeds exactly once.292300 worldFeed.append(frame.world);
@@ -376,7 +384,9 @@ function buildViewer(
376384 dashboard.render();377385 dashDirty = false;378386 }379- if (mapDirty && mode === 'world' && latestFrame && latestFrame.hasMap) {387+ // Redraw on new frames (mapDirty) AND every rAF while effects animate —388+ // the terrain layer stays cached, only the entity composite re-runs.389+ if ((mapDirty || effects.active()) && mode === 'world' && latestFrame && latestFrame.hasMap) {380390 mapView.draw(latestFrame);381391 mapDirty = false;382392 }
@@ -432,6 +442,7 @@ function buildViewer(
432442 playBtn.textContent = 'pause';433443 worldFeed.reset();434444 for (const tf of tribeFeeds) tf.reset();445+ effects.clear();435446 if (!instant) {436447 // live-run page: the chronicle rebuilds with the replay437448 clear(banner);
@@ -455,6 +466,8 @@ function buildViewer(
455466 fitBtn.onclick = () => mapView.resetView();456467 povSel.onchange = () => {457468 // POV only affects the map — feeds are per-tribe channels.469+ // Drop live effects: the old POV's battles may be fogged for the new one.470+ effects.clear();458471 runner?.setPov(Number(povSel.value));459472 };460473
A web/src/render/effects.ts +317 -0
@@ -0,0 +1,317 @@
1+/**2+ * Transient map effects — the visual twin of the event feed.3+ *4+ * Effects are pure presentation: derived from the frame event channels5+ * (already fog-filtered per POV by the engine), aged by WALL CLOCK so they6+ * stay legible at any sim speed (a 10-tick fight at max speed still gets its7+ * full animation), and never touching engine state. The viewer's paint loop8+ * keeps repainting while any effect is alive; with the list empty this module9+ * costs nothing and the dirty-flag economy is unchanged.10+ *11+ * Grammar (shared primitives, composed per event kind):12+ * - ring: expanding circle, radius/color say magnitude/ownership13+ * - pop: glyph scale-in through discrete steps (reads 1-bit, not tweeny)14+ * - fade: glyph dissolving out (things leaving the world)15+ * - flame: flickering effect.fire overlay (violence against places)16+ * - float: black-backed text lines rising off the tile (numbers)17+ *18+ * Tiers set duration and eviction priority: 1 = world-shaking (captures),19+ * 2 = local drama (battles, raids), 3 = economic texture (buildings). When20+ * the cap is hit, texture dies before drama. Same-tile repeats of one kind21+ * coalesce (battles sum losses; others restart) so max speed pulses instead22+ * of strobing. Events without map coordinates (tech, age, eliminated) stay23+ * feed-only by design.24+ */25+import type { SimEvent } from '@tribes/protocol';26+import type { MapFrame } from '../sim/frames';27+import { tribeColor } from '../viz/palette';28+import { TILE, type UrizenAtlas } from './urizen';29+30+/** Threat fights, stand-offs, neutral raiders — matches the threat accent. */31+const NEUTRAL = '#c0392b';32+/** Losses/deaths float color (danger red from the chart palette family). */33+const DANGER = '#e66767';34+const MAX_EFFECTS = 24;35+const TIER_MS: Record<1 | 2 | 3, number> = { 1: 2200, 2: 1100, 3: 900 };36+37+interface Fx {38+ kind: SimEvent['t'];39+ tier: 1 | 2 | 3;40+ x: number;41+ y: number;42+ born: number;43+ duration: number;44+ ring?: { color: string; maxR: number; double?: boolean };45+ pop?: { glyph: string; tint?: string };46+ fade?: { glyph: string; tint?: string };47+ flame?: boolean;48+ floats: { text: string; color: string }[];49+ /** battle only — accumulated across same-tile coalesces */50+ aL?: number;51+ dL?: number;52+ attacker?: number;53+ defender?: number | null;54+}55+56+export interface MapEffects {57+ /** Pull effect-worthy events off a frame's feed channels (POV-aware). */58+ ingest(frame: MapFrame): void;59+ /** True while anything is animating — the paint loop keeps redrawing. */60+ active(): boolean;61+ /** Composite pass, drawn in world px over entities (called by map-view). */62+ draw(ctx: CanvasRenderingContext2D, atlas: UrizenAtlas | null, now: number): void;63+ /** Drop everything (restart, POV switch — old POV's effects may be fogged). */64+ clear(): void;65+}66+67+const easeOutCubic = (t: number): number => 1 - (1 - t) ** 3;68+69+const lootFloats = (loot: Partial<Record<string, number>>): { text: string; color: string }[] =>70+ Object.entries(loot)71+ .filter(([, v]) => (v ?? 0) > 0)72+ .sort(([, a], [, b]) => (b ?? 0) - (a ?? 0))73+ .slice(0, 2)74+ .map(([k, v]) => ({ text: `-${v} ${k}`, color: DANGER }));75+76+const battleFloats = (fx: Fx): void => {77+ fx.floats = [];78+ if ((fx.aL ?? 0) > 0) fx.floats.push({ text: `-${fx.aL}`, color: tribeColor(fx.attacker ?? 0) });79+ if ((fx.dL ?? 0) > 0)80+ fx.floats.push({ text: `-${fx.dL}`, color: fx.defender != null ? tribeColor(fx.defender) : NEUTRAL });81+};82+83+/** Build the visual for one anchored event, or null for kinds with no map84+ * effect (says, techs, ages — the feed carries those). */85+function makeFx(ev: SimEvent, now: number): Fx | null {86+ const base = <T extends 1 | 2 | 3>(tier: T, x: number, y: number): Fx => ({87+ kind: ev.t,88+ tier,89+ x,90+ y,91+ born: now,92+ duration: TIER_MS[tier],93+ floats: [],94+ });95+ switch (ev.t) {96+ case 'battle': {97+ const fx = base(2, ev.x, ev.y);98+ const losses = ev.attackerLosses + ev.defenderLosses;99+ fx.ring = {100+ color: ev.winner != null ? tribeColor(ev.winner) : NEUTRAL,101+ maxR: TILE * (0.8 + Math.min(2.2, Math.sqrt(losses) / 6)),102+ };103+ fx.pop = { glyph: 'effect.battle', tint: '#d64545' };104+ fx.aL = ev.attackerLosses;105+ fx.dL = ev.defenderLosses;106+ fx.attacker = ev.attacker;107+ fx.defender = ev.defender;108+ battleFloats(fx);109+ return fx;110+ }111+ case 'settlement_captured': {112+ // Tier 1: the city burns, the captor's shockwave rolls out.113+ const fx = base(1, ev.x, ev.y);114+ fx.ring = { color: tribeColor(ev.by), maxR: TILE * 3, double: true };115+ fx.flame = true;116+ return fx;117+ }118+ case 'settlement_founded': {119+ const fx = base(2, ev.x, ev.y);120+ fx.ring = { color: tribeColor(ev.tribe), maxR: TILE * 1.8 };121+ fx.pop = { glyph: 'banner.flag', tint: tribeColor(ev.tribe) };122+ return fx;123+ }124+ case 'raid': {125+ const fx = base(2, ev.x, ev.y);126+ fx.ring = { color: ev.by >= 0 ? tribeColor(ev.by) : NEUTRAL, maxR: TILE * 1.4 };127+ fx.floats = lootFloats(ev.loot);128+ return fx;129+ }130+ case 'caravan_raided': {131+ const fx = base(2, ev.x, ev.y);132+ fx.ring = { color: tribeColor(ev.by), maxR: TILE * 1.2 };133+ fx.floats = [{ text: `-${ev.amount} ${ev.resource}`, color: DANGER }];134+ return fx;135+ }136+ case 'building_complete': {137+ const fx = base(3, ev.x, ev.y);138+ fx.ring = { color: tribeColor(ev.tribe), maxR: TILE * 1.1 };139+ fx.pop = { glyph: `building.${ev.building}` };140+ return fx;141+ }142+ case 'building_destroyed': {143+ const fx = base(2, ev.x, ev.y);144+ fx.flame = true;145+ fx.floats = [{ text: `-${ev.building.replace(/_/g, ' ')}`, color: DANGER }];146+ return fx;147+ }148+ case 'starvation': {149+ const fx = base(2, ev.x, ev.y);150+ fx.ring = { color: NEUTRAL, maxR: TILE * 1.2 };151+ fx.floats = [{ text: `-${ev.deaths} starved`, color: DANGER }];152+ return fx;153+ }154+ case 'threat_spawned': {155+ const fx = base(2, ev.x, ev.y);156+ fx.ring = { color: NEUTRAL, maxR: TILE * 1.6 };157+ fx.pop =158+ ev.kind === 'demon_gate'159+ ? { glyph: 'effect.portal' }160+ : { glyph: `threat.${ev.kind}` };161+ return fx;162+ }163+ case 'threat_cleared': {164+ const fx = base(2, ev.x, ev.y);165+ fx.ring = { color: tribeColor(ev.tribe), maxR: TILE * 1.4 };166+ fx.fade = { glyph: `threat.${ev.kind}` };167+ return fx;168+ }169+ default:170+ return null;171+ }172+}173+174+export function createEffects(): MapEffects {175+ let effects: Fx[] = [];176+177+ const prune = (now: number): void => {178+ effects = effects.filter((fx) => now - fx.born < fx.duration);179+ };180+181+ function spawn(ev: SimEvent, now: number): void {182+ const fx = makeFx(ev, now);183+ if (!fx) return;184+ const live = effects.find((e) => e.kind === fx.kind && e.x === fx.x && e.y === fx.y);185+ if (live) {186+ // Same-tile repeat: fold in and restart, don't stack.187+ live.born = now;188+ if (live.kind === 'battle') {189+ live.aL = (live.aL ?? 0) + (fx.aL ?? 0);190+ live.dL = (live.dL ?? 0) + (fx.dL ?? 0);191+ live.ring = fx.ring;192+ battleFloats(live);193+ } else {194+ live.ring = fx.ring;195+ live.pop = fx.pop;196+ live.fade = fx.fade;197+ live.floats = fx.floats;198+ }199+ return;200+ }201+ effects.push(fx);202+ if (effects.length > MAX_EFFECTS) {203+ // Evict texture before drama: lowest tier (highest number), oldest first.204+ let evict = 0;205+ for (let i = 1; i < effects.length; i++) {206+ const a = effects[i]!;207+ const b = effects[evict]!;208+ if (a.tier > b.tier || (a.tier === b.tier && a.born < b.born)) evict = i;209+ }210+ effects.splice(evict, 1);211+ }212+ }213+214+ return {215+ ingest(frame) {216+ const now = performance.now();217+ // An event can ride several channels (attacker's, defender's, world) —218+ // dedupe within this frame's batch before spawning.219+ const seen = new Set<string>();220+ const take = (evs: SimEvent[] | undefined): void => {221+ for (const ev of evs ?? []) {222+ if (!('x' in ev && 'y' in ev)) continue;223+ const key = `${ev.t}|${ev.tick}|${ev.x},${ev.y}|${'tribe' in ev ? ev.tribe : ''}|${'by' in ev ? ev.by : ''}|${'attacker' in ev ? ev.attacker : ''}`;224+ if (seen.has(key)) continue;225+ seen.add(key);226+ spawn(ev, now);227+ }228+ };229+ if (frame.pov >= 0) {230+ // Tribe POV: its own channel is already fog-filtered by the engine.231+ take(frame.eventsByTribe[frame.pov]);232+ } else {233+ take(frame.world);234+ for (const evs of Object.values(frame.eventsByTribe)) take(evs);235+ }236+ },237+238+ active() {239+ prune(performance.now());240+ return effects.length > 0;241+ },242+243+ draw(ctx, atlas, now) {244+ prune(now);245+ if (effects.length === 0) return;246+ // Tier 1 draws last so world-shaking beats sit on top of texture.247+ const ordered = [...effects].sort((a, b) => b.tier - a.tier);248+ ctx.save();249+ ctx.font = '7px ui-monospace, monospace';250+ ctx.textBaseline = 'top';251+ for (const fx of ordered) {252+ const t = Math.min(1, (now - fx.born) / fx.duration);253+ const ease = easeOutCubic(t);254+ const cx = fx.x * TILE + TILE / 2;255+ const cy = fx.y * TILE + TILE / 2;256+257+ if (fx.ring) {258+ const drawRing = (p: number): void => {259+ if (p <= 0) return;260+ const e = easeOutCubic(p);261+ ctx.globalAlpha = 0.9 * (1 - e);262+ ctx.strokeStyle = fx.ring!.color;263+ ctx.lineWidth = fx.tier === 1 ? 1.5 : 1;264+ ctx.beginPath();265+ ctx.arc(cx, cy, TILE * 0.4 + (fx.ring!.maxR - TILE * 0.4) * e, 0, Math.PI * 2);266+ ctx.stroke();267+ };268+ drawRing(t);269+ if (fx.ring.double) drawRing((t - 0.3) / 0.7); // second shockwave270+ }271+272+ if (fx.pop && atlas) {273+ // Stepped scale-in (reads 1-bit), fade in the last quarter.274+ const scale = t < 0.15 ? 1.7 : t < 0.3 ? 1.35 : 1;275+ const size = TILE * scale;276+ ctx.globalAlpha = t > 0.75 ? (1 - t) / 0.25 : 1;277+ atlas.draw(ctx, fx.pop.glyph, cx - size / 2, cy - size / 2, size, fx.pop.tint);278+ }279+280+ if (fx.fade && atlas) {281+ ctx.globalAlpha = 1 - ease;282+ atlas.draw(ctx, fx.fade.glyph, fx.x * TILE, fx.y * TILE, TILE, fx.fade.tint);283+ }284+285+ if (fx.flame && atlas) {286+ // Flicker: blink + size alternation on ~130ms steps, out at the end.287+ const step = Math.floor((now - fx.born) / 130);288+ if (step % 3 !== 2) {289+ const size = step % 2 === 0 ? TILE : TILE * 1.25;290+ ctx.globalAlpha = t > 0.8 ? (1 - t) / 0.2 : 0.95;291+ atlas.draw(ctx, 'effect.fire', cx - size / 2, cy - size / 2 - 2, size);292+ }293+ }294+295+ if (fx.floats.length > 0) {296+ const rise = ease * 8;297+ ctx.globalAlpha = t > 0.6 ? Math.max(0, (1 - t) / 0.4) : 1;298+ let y = fx.y * TILE - 8 - rise;299+ for (const line of fx.floats) {300+ const w = ctx.measureText(line.text).width + 2;301+ const bx = cx - w / 2;302+ ctx.fillStyle = 'rgba(0,0,0,0.75)';303+ ctx.fillRect(bx, y, w, 7);304+ ctx.fillStyle = line.color;305+ ctx.fillText(line.text, bx + 1, y);306+ y += 8;307+ }308+ }309+ }310+ ctx.restore();311+ },312+313+ clear() {314+ effects = [];315+ },316+ };317+}
M web/src/render/map-view.ts +5 -1
M web/tsconfig.json +1 -0