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 */25import type { SimEvent } from '@tribes/protocol';26import type { MapFrame } from '../sim/frames';27import { tribeColor } from '../viz/palette';28import { TILE, type UrizenAtlas } from './urizen';2930/** Threat fights, stand-offs, neutral raiders — matches the threat accent. */31const NEUTRAL = '#c0392b';32/** Losses/deaths float color (danger red from the chart palette family). */33const DANGER = '#e66767';34const MAX_EFFECTS = 24;35const TIER_MS: Record<1 | 2 | 3, number> = { 1: 2200, 2: 1100, 3: 900 };3637interface 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}5556export 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}6667const easeOutCubic = (t: number): number => 1 - (1 - t) ** 3;6869const 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 }));7576const 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};8283/** Build the visual for one anchored event, or null for kinds with no map84 * effect (says, techs, ages — the feed carries those). */85function 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}173174export function createEffects(): MapEffects {175 let effects: Fx[] = [];176177 const prune = (now: number): void => {178 effects = effects.filter((fx) => now - fx.born < fx.duration);179 };180181 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 }213214 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 },237238 active() {239 prune(performance.now());240 return effects.length > 0;241 },242243 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;256257 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 }271272 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 }279280 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 }284285 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 }294295 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 },312313 clear() {314 effects = [];315 },316 };317}