clone

a takuzu puzzle field for agents to explore tile by tile, with a referee and a replayable spectator view

viewer: hues wilt with health; dead agents marked; death markers on the timeline

dami committed 9907486
commit
9907486254bfcfc174c68d689603c8c7217b1b62
parent
7e075f19fcbd3030b080619693ce3ae7160eec49
author
dami <gazey55@gmail.com>
3 files changed, +145 -16
Msite/islands/Viewer.tsx132+++++++++++++++++++++++++++---
Msite/lib/colour.ts15+++-
Msite/lib/viewer/timeline.ts14+++-
M site/islands/Viewer.tsx +120 -12
@@ -15,6 +15,9 @@ import {
1515 emptyState,1616 type GameEvent,1717 type Geometry,18+ type Health,19+ HEALTH_DEATH,20+ HEALTH_WARN,1821 parseLog,1922 type State,2023 type TileSpec,
@@ -51,6 +54,7 @@ import {
5154 import { agentHues } from "../lib/viewer/hues.ts";5255 import { Checkpoints } from "../lib/viewer/checkpoints.ts";5356 import {57+ type Death,5458 density,5559 type Marker,5660 markers,
@@ -154,13 +158,21 @@ class Scene {
154158 status: TileStatus,155159 known: boolean,156160 recency: number,161+ wilt = 0,157162 ): string {158163 const q = Math.round(recency * RECENCY_STEPS);159- const key = `${hues.join(",")}|${status}|${known ? 1 : 0}|${q}`;164+ const key = `${hues.join(",")}|${status}|${known ? 1 : 0}|${q}|${wilt}`;160165 let f = this.fillCache.get(key);161166 if (!f) {162167 f = hues.length163- ? cellFill(hues, status, known, this.theme, q / RECENCY_STEPS)168+ ? cellFill(169+ hues,170+ status,171+ known,172+ this.theme,173+ q / RECENCY_STEPS,174+ WILT_CHROMA[wilt] ?? 1,175+ )164176 : neutralFill(status, known, this.theme, q / RECENCY_STEPS);165177 this.fillCache.set(key, f);166178 }
@@ -174,6 +186,8 @@ type Frame = {
174186 tileHue: Int16Array; // per tile: agent hue or -1175187 agentName: Map<string, string>;176188 reached: Uint8Array; // per tile: 1 if some territory has reached it189+ tileWilt: Uint8Array; // per tile: its agent's wilt level 0..3 (health)190+ agentWilt: Map<string, number>;177191 final: boolean; // the last revision of a finished game: never-reached tiles are shown178192 };179193
@@ -203,10 +217,41 @@ function frameOf(s: State, hues: Map<string, number>, final = false): Frame {
203217 }204218 }205219 const agentName = new Map<string, string>();206- for (const a of s.agents) agentName.set(a.id, a.name || a.id);207- return { s, tileHue, agentName, reached: reachedOf(s), final };220+ const agentWilt = new Map<string, number>();221+ for (const a of s.agents) {222+ agentName.set(a.id, a.name || a.id);223+ agentWilt.set(a.id, wiltOf(a.health));224+ }225+ const tileWilt = new Uint8Array(n);226+ for (let t = 0; t < n; t++) {227+ const a = s.tileAgent[t] ?? revealer.get(t) ?? null;228+ if (a !== null) tileWilt[t] = agentWilt.get(a) ?? 0;229+ }230+ return {231+ s,232+ tileHue,233+ agentName,234+ reached: reachedOf(s),235+ tileWilt,236+ agentWilt,237+ final,238+ };208239 }209240 241+/** Wilt level from health: 0 healthy, 1-2 warning (rate rising toward242+ * death), 3 dead. The hue desaturates with it. */243+export function wiltOf(h: Health | undefined): number {244+ if (!h) return 0;245+ if (h.state === "dead") return 3;246+ if (h.state === "warning") {247+ const k = (h.rate - HEALTH_WARN) / (HEALTH_DEATH - HEALTH_WARN);248+ return k < 0.5 ? 1 : 2;249+ }250+ return 0;251+}252+253+const WILT_CHROMA = [1, 0.7, 0.45, 0.22];254+210255 // ---------------------------------------------------------------- raster211256 212257 type Raster = { canvas: HTMLCanvasElement; ppc: number }; // pixels per cell
@@ -238,11 +283,17 @@ function renderMapTile(
238283 }239284 return best;240285 };286+ let cellWilt = 0;241287 const huesOfCell = (id: number, out: number[]) => {242288 out.length = 0;289+ cellWilt = 0;243290 for (let i = geo.ownerStart[id]; i < geo.ownerStart[id + 1]; i++) {244- const h = f.tileHue[geo.ownerList[i]];245- if (h >= 0) out.push(h);291+ const t = geo.ownerList[i];292+ const h = f.tileHue[t];293+ if (h >= 0) {294+ out.push(h);295+ if (f.tileWilt[t] > cellWilt) cellWilt = f.tileWilt[t];296+ }246297 }247298 if (out.length > 1) out.sort((p, q) => p - q);248299 };
@@ -267,7 +318,7 @@ function renderMapTile(
267318 const age = c ? s.revision - c.rev : RECENCY_REVS;268319 const recency = c && age < RECENCY_REVS ? 1 - age / RECENCY_REVS : 0;269320 huesOfCell(id, hues);270- return scene.fill(hues, st, !!c, recency);321+ return scene.fill(hues, st, !!c, recency, cellWilt);271322 };272323 273324 if (ppc <= 2 * dpr) {
@@ -338,6 +389,24 @@ function renderMapTile(
338389 traceOutline(ctx, t, scene.masks[id], ppc, -cx0 * ppc, -cy0 * ppc);339390 }340391 ctx.globalAlpha = 1;392+ // a dead agent: a small cross on its last solved tile393+ for (const an of s.analysis.agents) {394+ if (an.health.state !== "dead" || an.last_solved === null) continue;395+ const t = s.manifest!.tiles[an.last_solved];396+ if (!t || !tiles.has(t.id)) continue;397+ const cx = (t.x0 + t.w / 2 - cx0) * ppc, cy = (t.y0 + t.h / 2 - cy0) * ppc;398+ const r = Math.max(3, Math.min(t.w, t.h) * ppc * 0.18);399+ ctx.strokeStyle = theme.muted;400+ ctx.globalAlpha = 0.9;401+ ctx.lineWidth = Math.max(1, r * 0.22);402+ ctx.beginPath();403+ ctx.moveTo(cx - r, cy - r);404+ ctx.lineTo(cx + r, cy + r);405+ ctx.moveTo(cx + r, cy - r);406+ ctx.lineTo(cx - r, cy + r);407+ ctx.stroke();408+ }409+ ctx.globalAlpha = 1;341410 if (level === 1) return { canvas, ppc };342411 343412 // L2: values as discs in a tone of the wash. A 1 is a filled disc, a 0
@@ -661,12 +730,14 @@ const MARKER_COLOUR: Record<Marker["kind"], string> = {
661730 solved: "#9ece6a",662731 meet: "#f7768e",663732 "guess-run": "#e0af68",733+ death: "#8c8c92",664734 grow: "#bb9af7",665735 end: "#8c8c92",666736 };667737 668738 function Timeline(props: {669739 events: GameEvent[];740+ deaths: Death[];670741 rev: number;671742 max: number;672743 live: boolean;
@@ -674,7 +745,10 @@ function Timeline(props: {
674745 theme: Theme;675746 }) {676747 const ref = useRef<HTMLCanvasElement>(null);677- const marks = useMemo(() => markers(props.events), [props.events]);748+ const marks = useMemo(749+ () => markers(props.events, props.deaths),750+ [props.events, props.deaths],751+ );678752 const dens = useMemo(() => density(props.events, 240), [props.events]);679753 useEffect(() => {680754 const c = ref.current;
@@ -705,6 +779,10 @@ function Timeline(props: {
705779 ctx.fill();706780 } else if (m.kind === "guess-run") {707781 ctx.fillRect(x - 1, 2, 2, 8);782+ } else if (m.kind === "death") {783+ // a small cross: an agent bled out here784+ ctx.fillRect(x - 3, 5, 6, 1.5);785+ ctx.fillRect(x - 0.75, 2, 1.5, 8);708786 } else if (m.kind === "grow") {709787 // a small chevron: the field opened here710788 ctx.beginPath();
@@ -870,6 +948,16 @@ export default function Viewer(
870948 () => (events.length ? cps.current.at(events.length - 1) : state),871949 [events],872950 );951+ // deaths, read off the final state: the timeline marks them952+ const deaths = useMemo(953+ () =>954+ final.agents.filter((a) => a.health.died_at !== null).map((a) => ({955+ rev: a.health.died_at!,956+ agent: a.id,957+ name: a.name || a.id,958+ })),959+ [final],960+ );873961 874962 // hues are assigned from the full agent list, so a scrubbed frame and875963 // the final frame agree
@@ -1143,7 +1231,14 @@ export default function Viewer(
11431231 );11441232 const agentRows = final.agents.map((a: AgentInfo) => {11451233 const s = an.agents.find((x) => x.agent === a.id);1146- return { a, s, hue: hues.get(a.id) ?? 0, crowned: honoured(a) };1234+ return {1235+ a,1236+ s,1237+ hue: hues.get(a.id) ?? 0,1238+ crowned: honoured(a),1239+ wilt: wiltOf(s?.health ?? a.health),1240+ health: s?.health ?? a.health,1241+ };11471242 }).sort((x, y) => Number(y.crowned) - Number(x.crowned));11481243 11491244 return (
@@ -1213,18 +1308,24 @@ export default function Viewer(
12131308 {agentRows.length === 012141309 ? <div style="color:var(--muted)">no agents yet</div>12151310 : null}1216- {agentRows.map(({ a, s, hue, crowned }) => (1311+ {agentRows.map(({ a, s, hue, crowned, wilt, health }) => (12171312 <div12181313 key={a.id}12191314 style="display:flex;align-items:baseline;gap:8px;padding:2px 0"12201315 >12211316 <span12221317 style={`display:inline-block;width:10px;height:10px;border-radius:50%;background:${1223- agentColour(hue, theme, true)1318+ agentColour(hue, theme, true, WILT_CHROMA[wilt])12241319 };flex:none;align-self:center`}1320+ title={`health ${health.state}: ${1321+ (health.rate * 100).toFixed(1)1322+ }% wrong over ${health.committed} cells`}12251323 />12261324 <span style="color:var(--text-strong);min-width:70px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">12271325 {crowned && <span style="color:#d4a83a">&#9813;</span>}1326+ {health.state === "dead" && (1327+ <span style="color:var(--muted)">&#10005;</span>1328+ )}12281329 {a.name || a.id}12291330 </span>12301331 <span style="color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
@@ -1235,7 +1336,13 @@ export default function Viewer(
12351336 {s12361337 ? `${s.tiles}t ${s.cells}c ${12371338 (s.territory * 100).toFixed(0)1238- }% ${s.strategy}${s.meets ? ` m${s.meets}` : ""}`1339+ }% ${s.strategy}${s.meets ? ` m${s.meets}` : ""} ${1340+ health.state === "dead"1341+ ? "dead"1342+ : `${(health.rate * 100).toFixed(1)}%${1343+ health.state === "warning" ? "!" : ""1344+ }`1345+ }`12391346 : "-"}12401347 </span>12411348 </div>
@@ -1310,6 +1417,7 @@ export default function Viewer(
13101417 </span>13111418 <Timeline13121419 events={events}1420+ deaths={deaths}13131421 rev={rev}13141422 max={max}13151423 live={live}
M site/lib/colour.ts +12 -3
M site/lib/viewer/timeline.ts +13 -1