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>
| M | packages/ | 14 | + |
| M | packages/ | 8 | +- |
| M | web/ | 8 | +- |
| M | web/ | 19 | ++- |
| A | web/ | 317 | ++++++++++++++++++++++++++++++ |
| M | web/ | 6 | +- |
| M | web/ | 1 | + |
@@ -1770,6 +1770,8 @@ class World implements EngineWorld {
17701770 tribe: s.tribe,17711771 building: BUILDINGS[site.b]!,17721772 settlementId: s.id,1773+ x: s.x,1774+ y: s.y,17731775 });17741776 }17751777 }@@ -2036,6 +2038,8 @@ class World implements EngineWorld {
20362038 tribe: s.tribe,20372039 settlementId: s.id,20382040 deaths,2041+ x: s.x,2042+ y: s.y,20392043 });20402044 }20412045 // Ale ration.@@ -2386,6 +2390,8 @@ class World implements EngineWorld {
23862390 building: BUILDINGS[b]!,23872391 settlementId: s.id,23882392 by: w.tribe,2393+ x: s.x,2394+ y: s.y,23892395 });23902396 break;23912397 }@@ -2401,6 +2407,8 @@ class World implements EngineWorld {
24012407 by: w.tribe,24022408 settlementId: s.id,24032409 loot,2410+ x: s.x,2411+ y: s.y,24042412 });24052413 } else {24062414 const { winnerPm, loserPm } = this.casualties(pd, pa);@@ -2534,6 +2542,8 @@ class World implements EngineWorld {
25342542 building: 'walls',25352543 settlementId: s.id,25362544 by: w.tribe,2545+ x: s.x,2546+ y: s.y,25372547 });25382548 }25392549 return;@@ -2950,6 +2960,8 @@ class World implements EngineWorld {
29502960 building: BUILDINGS[b]!,29512961 settlementId: target.id,29522962 by: null,2963+ x: target.x,2964+ y: target.y,29532965 });29542966 break;29552967 }@@ -2963,6 +2975,8 @@ class World implements EngineWorld {
29632975 by: -1,29642976 settlementId: target.id,29652977 loot,2978+ x: target.x,2979+ y: target.y,29662980 });29672981 } else {29682982 // Repelled: the camp is bloodied.@@ -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 }@@ -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 }@@ -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 @@ -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+}@@ -14,6 +14,7 @@ import type { MapFrame } from '../sim/frames';
1414 import type { ThreatKind, WarbandKind } from '@tribes/protocol';1515 import { log } from '../log';1616 import { tribeColor } from '../viz/palette';17+import type { MapEffects } from './effects';1718 import { loadUrizen, TILE, type UrizenAtlas } from './urizen';1819 import {1920 cellHash,@@ -63,7 +64,7 @@ function fmtCount(n: number): string {
6364 6465 export function createMapView(6566 container: HTMLElement,66- opts: { mapSize: number; names: string[] },67+ opts: { mapSize: number; names: string[]; effects?: MapEffects },6768 ): {6869 draw(frame: MapFrame): void;6970 attachTooltips(getFrame: () => MapFrame | null): { dispose(): void };@@ -469,6 +470,9 @@ export function createMapView(
469470 }470471 }471472 473+ // transient event effects (battles, …) — over entities, under the minimap474+ opts.effects?.draw(ctx, atlas, performance.now());475+472476 drawMinimap(); // keep the corner snapshot in sync with the world473477 }474478