PROCITY/web/js/world/minimap.js
m3ultra 0ee7a84710 Lane B R39 39.2: THE FOG — the map shows what you walked past, and is finally readable
minimap.js drew every lot in the plan from the first frame (493 shops on the synthetic, one M
press, against 5,330 m / 4.8 game-days of walking to earn it). It now draws what has been walked
past AND FRAMES ITSELF TO IT — a shop lot goes 0.83x1.11 px -> 29.4x39.2 px on bowral_real, 35.3x.

ZERO DRAWS, measured as an A/B: two port-isolated no-store servers, HEAD vs treatment, the same 15
stations x 4 yaws compared as JSON before either sweep runs. Draw calls IDENTICAL on 60/60 samples
at noon and at night; with the gig layer off both sides (the venue queue's async rigs were the only
noise) NIGHT is identical in draws AND triangles. Map mode cannot cost a draw structurally: the
frame loop never calls composer.render() in the map branch.

- discovery.js (new): a 25 m PROXIMITY PROBE, not onChunkBuilt (R=2 => the outermost live chunk
  corner is 181 m out), on a plan-derived spatial hash — 1.09-1.37 us a probe, every 6th street
  frame = every 0.46 m at WALK. Plus a side test: you must be in FRONT of the shopfront. Control
  with a red arm: walking the synthetic's two service laneways, radius-only reveals 93 shops, the
  shipped probe 59 — 34 refused through the back wall; on the arcade it refuses 0 of 17.
- save.js: optional `known` block under the delta law, keyed by TOWN (re-derived: 23 caches x 3
  seeds, shop/edge ids and lot geometry all identical; the synthetic alone keys @<seed>). Bounded
  by the plan (max 493 shops / 2,593 edges) at 8,192, 64 towns FIFO, ints only. Whole corpus fully
  learned = 137,547 B (2.6% of quota); 482 B after a 500 m outing. Eight reject arms demonstrated.
- classic BY CONSTRUCTION: createMinimap(plan, null) is the pre-R39 map and the file has no flag
  test at all. ?classic=1 / ?fog=0 / ?game=0 map canvases are hash-identical to HEAD's.
- Lane A's address layer consumed (getTownCache + createAddresses): street names along walked
  streets, deduped; the synthetic gets district labels from the same line. No town-type branch.

FILED, NOT FIXED: index.html:384 (Lane D patronage door points) and :450 (the R32 spawn) use
(-sin ry, -cos ry) — the BACK of the building. buildings.js:410/583-597, dbg.js:53 and minimap.js
all use local +Z. Measured against each lot's own frontEdge the +sin point is 0.00 m from the kerb
and the shipped one 15.25 m the wrong side, 493/493 and 72/72 shops. At the shipped spawn 0 of the
shops within 25 m are in front of you on all four towns tested; at the corrected one, all of them.
Two characters, but it moves the default boot's opening pose mid-round — Fable's call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 21:04:34 +10:00

309 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// PROCITY Lane B — minimap.js
// The `map` mode: a 2D top-down town directory drawn from the CityPlan (streets + lots coloured by
// shop type + a live player dot/heading). This is the shell's own lightweight map; Lane A's
// map.html debug view grows into the richer directory later (Lane F reconciles).
//
// [R39 — v9 Layer 2, THE FOG] `createMinimap(plan, known, opts)`.
//
// known === null ⇒ EXACTLY the pre-R39 map: every lot, every street, full-plan extent, no labels,
// no cache. That is what ?classic=1 / ?game=0 / ?fog=0 hand it, so THIS FILE
// CONTAINS NO FLAG TEST — the classic guarantee is by construction, the same
// shape ground.js gets from townCharacter(null) (index.html:178-185).
// known !== null ⇒ the map draws only what has been walked past (discovery.js writes the store,
// save.js keeps it) AND FRAMES ITSELF TO IT. The framing is the point, not a
// bonus: the full-plan transform renders a shop rectangle at 0.83×1.11 px on
// bowral_real and 1.42×1.89 on katoomba_real — the 21 towns that are supposed to
// be 21 mental maps have a map you cannot read. The same line that hides what you
// don't know makes legible what you do.
//
// Zero draws, zero triangles, in both arms: this is a 2D canvas over the WebGL canvas and
// `hud.js:361`'s `renderer.info.render.calls` never sees it. Measured, not asserted — see §39.
//
// The static layer is CACHED to an offscreen canvas in the fog arm and re-rendered only when the
// discovery version or the (quantised) frame box changes. The null arm keeps re-filling every lot per
// frame exactly as it always has: identical work, identical pixels, nothing new to be wrong.
const TYPE_COLOR = {
record: '#8a5aa8', opshop: '#8a7a5a', toy: '#c86aa0', book: '#7a6a3a', video: '#4a6ab0',
pawn: '#c4a028', milkbar: '#c4863a', dept: '#5a6a7a', stall: '#5a8a6a',
house: '#6b5a48', anchor: '#5a6a7a',
};
const MIN_SPAN_M = 240; // the tightest frame: a 200 m window + the transform's own 40 m margin
const FRAME_Q = 25; // frame box quantisation (m) — walking inside what you know re-renders nothing
const MAX_LABELS = 40;
export function createMinimap(plan, known = null, opts = {}) {
let addresses = opts.addresses || null; // Lane A's createAddresses(): streetOf / localityOf
const wrap = document.createElement('div');
wrap.id = 'pc-map';
wrap.style.cssText = 'position:fixed;inset:0;z-index:20;display:none;background:#141712;'
+ 'align-items:center;justify-content:center;flex-direction:column;font:13px -apple-system,sans-serif;color:#e8e0d0';
const canvas = document.createElement('canvas');
canvas.width = 720; canvas.height = 720;
canvas.style.cssText = 'background:#1b1e17;border-radius:10px;max-width:92vw;max-height:80vh;box-shadow:0 8px 40px #000';
const cap = document.createElement('div');
cap.style.cssText = 'margin-top:12px;opacity:.8';
cap.textContent = `${plan.name} — seed ${plan.citySeed} · press M to close`;
wrap.append(canvas, cap);
document.body.appendChild(wrap);
const ctx = canvas.getContext('2d');
// world→canvas transform (fit plan extent with margin)
let minX = -60, maxX = 60, minZ = -60, maxZ = 60;
for (const n of plan.streets.nodes) { minX = Math.min(minX, n.x); maxX = Math.max(maxX, n.x); minZ = Math.min(minZ, n.z); maxZ = Math.max(maxZ, n.z); }
for (const l of plan.lots) { minX = Math.min(minX, l.x); maxX = Math.max(maxX, l.x); minZ = Math.min(minZ, l.z); maxZ = Math.max(maxZ, l.z); }
const pad = 24, W = canvas.width, H = canvas.height;
const span = Math.max(maxX - minX, maxZ - minZ) + 40;
const cxw = (minX + maxX) / 2, czw = (minZ + maxZ) / 2;
const sc = (W - pad * 2) / span;
const tx = (x) => W / 2 + (x - cxw) * sc;
const tz = (z) => H / 2 + (z - czw) * sc;
const nodes = new Map(plan.streets.nodes.map((n) => [n.id, n]));
const shopByLot = new Map(plan.shops.map((s) => [s.lot, s]));
function drawStatic() {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#1b1e17'; ctx.fillRect(0, 0, W, H);
// streets
for (const e of plan.streets.edges) {
const a = nodes.get(e.a), b = nodes.get(e.b);
ctx.strokeStyle = '#3a3f34'; ctx.lineWidth = Math.max(3, e.width * sc);
ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(tx(a.x), tz(a.z)); ctx.lineTo(tx(b.x), tz(b.z)); ctx.stroke();
}
// lots
for (const l of plan.lots) {
const s = shopByLot.get(l.id);
const col = TYPE_COLOR[s ? s.type : l.use] || '#666';
ctx.save();
ctx.translate(tx(l.x), tz(l.z)); ctx.rotate(-(l.ry || 0));
ctx.fillStyle = col;
ctx.fillRect(-l.w * sc / 2, -l.d * sc / 2, l.w * sc, l.d * sc);
// front-edge tick (toward the street)
ctx.fillStyle = 'rgba(255,255,255,.5)';
ctx.fillRect(-l.w * sc / 2, l.d * sc / 2 - 2, l.w * sc, 2);
ctx.restore();
}
}
// ── the fog arm ────────────────────────────────────────────────────────────────────────────────
// Everything below is dead code when `known` is null: `draw()` branches once, at the top.
const off = known ? document.createElement('canvas') : null;
if (off) { off.width = W; off.height = H; }
const og = off ? off.getContext('2d') : null;
const lotById = known ? new Map(plan.lots.map((l) => [l.id, l])) : null;
const shopById = known ? new Map(plan.shops.map((s) => [s.id, s])) : null;
let cacheKey = null, lastCounts = null;
let T = { sc, tx, tz }; // the live transform (full-extent until something is known)
// The extent of what is KNOWN, recomputed only when the ledger changes (the player is unioned in
// per frame below — that part is two comparisons, not a walk of the ledger).
//
// SHOPFRONTS ONLY, DELIBERATELY. Including walked EDGES here was my first cut and it threw the
// framing away: an edge is drawn as the whole node-to-node line, and the synthetic's main street is
// one 400 m edge, so stepping onto it at spawn expanded the frame to 340 m before the player knew a
// single shop. Framing on the shops you have actually stood in front of (plus where you are standing)
// keeps the fresh-boot frame at the 240 m floor — 21×41 px on the synthetic, 29×39 on a real town —
// and a walked street that runs off the edge of the frame is simply clipped by the canvas, which is
// what a map does.
let kb = { v: -1, x0: Infinity, x1: -Infinity, z0: Infinity, z1: -Infinity };
function refreshKnownExtent() {
let x0 = Infinity, x1 = -Infinity, z0 = Infinity, z1 = -Infinity;
const eat = (x, z) => { if (x < x0) x0 = x; if (x > x1) x1 = x; if (z < z0) z0 = z; if (z > z1) z1 = z; };
for (const id of known.shopIds()) {
const s = shopById.get(id), l = s && lotById.get(s.lot);
if (l) { const r = Math.max(l.w, l.d) / 2; eat(l.x - r, l.z - r); eat(l.x + r, l.z + r); }
}
kb = { v: known.version(), x0, x1, z0, z1 };
}
function knownBox(px, pz) {
if (kb.v !== known.version()) refreshKnownExtent();
// the player is ALWAYS on the map — a frame you can walk off is a frame that lies
let x0 = Math.min(kb.x0, px - 20), x1 = Math.max(kb.x1, px + 20);
let z0 = Math.min(kb.z0, pz - 20), z1 = Math.max(kb.z1, pz + 20);
// quantise so ordinary walking inside what you know never invalidates the cache
x0 = Math.floor(x0 / FRAME_Q) * FRAME_Q; z0 = Math.floor(z0 / FRAME_Q) * FRAME_Q;
x1 = Math.ceil(x1 / FRAME_Q) * FRAME_Q; z1 = Math.ceil(z1 / FRAME_Q) * FRAME_Q;
// same +40 margin the full-extent transform uses, clamped: never tighter than a 200 m window,
// never wider than the whole plan (fully explored ⇒ exactly today's overview, which is the point
// at which today's overview is the right map).
let s2 = Math.max(x1 - x0, z1 - z0) + 40;
s2 = Math.max(MIN_SPAN_M, Math.min(span, s2));
return { cx: (x0 + x1) / 2, cz: (z0 + z1) / 2, span: s2, x0, x1, z0, z1 };
}
function paintFog(box) {
const s = (W - pad * 2) / box.span;
const fx = (x) => W / 2 + (x - box.cx) * s;
const fz = (z) => H / 2 + (z - box.cz) * s;
T = { sc: s, tx: fx, tz: fz };
og.clearRect(0, 0, W, H);
og.fillStyle = '#1b1e17'; og.fillRect(0, 0, W, H);
// streets you have walked
const drawn = [];
for (const e of plan.streets.edges) {
if (!known.hasEdge(e.id)) continue;
const a = nodes.get(e.a), b = nodes.get(e.b);
if (!a || !b) continue;
og.strokeStyle = '#3a3f34'; og.lineWidth = Math.max(3, e.width * s);
og.lineCap = 'round';
og.beginPath(); og.moveTo(fx(a.x), fz(a.z)); og.lineTo(fx(b.x), fz(b.z)); og.stroke();
drawn.push({ e, a, b });
}
// lots: a shop is drawn once you have walked past IT; a house/yard/infill rides the street it
// fronts (you saw the houses when you walked the road, and no house is a secret worth keeping).
for (const l of plan.lots) {
const sh = shopByLot.get(l.id);
if (sh ? !known.hasShop(sh.id) : !known.hasEdge(l.frontEdge)) continue;
const col = TYPE_COLOR[sh ? sh.type : l.use] || '#666';
og.save();
og.translate(fx(l.x), fz(l.z)); og.rotate(-(l.ry || 0));
og.fillStyle = col;
og.fillRect(-l.w * s / 2, -l.d * s / 2, l.w * s, l.d * s);
og.fillStyle = 'rgba(255,255,255,.5)';
og.fillRect(-l.w * s / 2, l.d * s / 2 - 2, l.w * s, 2);
og.restore();
}
paintLabels(og, drawn, s, fx, fz);
paintScale(og, s);
}
// Lane A's address layer, consumed WITHOUT branching on town type (§39.1's explicit guarantee):
// we ask a street for its name and a shop for its locality label, draw whatever answers, and a town
// that answers one way rather than the other is not a case we test for.
function paintLabels(g, drawn, s, fx, fz) {
if (!addresses) return;
const seen = new Set();
const out = [];
if (typeof addresses.streetOf === 'function') {
const byName = new Map();
for (const d of drawn) {
let name = null;
try { name = addresses.streetOf(d.e.id); } catch (err) { return; }
if (!name) continue;
const len = Math.hypot(d.b.x - d.a.x, d.b.z - d.a.z);
const prev = byName.get(name);
if (!prev || len > prev.len) byName.set(name, { d, len });
}
for (const [name, v] of byName) out.push({ text: name, kind: 'edge', d: v.d });
}
if (typeof addresses.localityOf === 'function') {
const byLabel = new Map();
for (const id of known.shopIds()) {
let loc = null;
try { loc = addresses.localityOf(id); } catch (err) { break; }
const text = loc && (loc.street || loc.label);
if (!text) continue;
const sh = shopById.get(id), l = sh && lotById.get(sh.lot);
if (!l) continue;
let b = byLabel.get(text);
if (!b) byLabel.set(text, b = { x: 0, z: 0, n: 0 });
b.x += l.x; b.z += l.z; b.n++;
}
for (const [text, b] of byLabel) out.push({ text, kind: 'spot', x: b.x / b.n, z: b.z / b.n });
}
g.font = '11px -apple-system, Segoe UI, sans-serif';
g.textAlign = 'center'; g.textBaseline = 'middle';
g.lineWidth = 3; g.strokeStyle = 'rgba(10,12,8,.85)';
let n = 0;
for (const it of out) {
if (n >= MAX_LABELS) break;
const key = it.text.toLowerCase();
if (seen.has(key)) continue;
const w = g.measureText(it.text).width;
g.save();
if (it.kind === 'edge') {
const ax = fx(it.d.a.x), az = fz(it.d.a.z), bx = fx(it.d.b.x), bz = fz(it.d.b.z);
if (Math.hypot(bx - ax, bz - az) < w + 10) { g.restore(); continue; }
let ang = Math.atan2(bz - az, bx - ax);
if (ang > Math.PI / 2 || ang < -Math.PI / 2) ang += Math.PI; // never upside down
g.translate((ax + bx) / 2, (az + bz) / 2); g.rotate(ang);
} else {
g.translate(fx(it.x), fz(it.z) - 10);
}
g.fillStyle = '#d9d2be';
g.strokeText(it.text, 0, 0); g.fillText(it.text, 0, 0);
g.restore();
seen.add(key); n++;
}
}
const SCALE_STEPS = [10, 20, 25, 50, 100, 200, 250, 500, 1000, 2000];
function paintScale(g, s) {
const want = (W - pad * 2) / s / 5; // ~a fifth of the window
let m = SCALE_STEPS[0];
for (const v of SCALE_STEPS) if (v <= want) m = v;
const px = m * s;
const x = pad, y = H - pad;
g.strokeStyle = 'rgba(232,224,208,.7)'; g.lineWidth = 2;
g.beginPath(); g.moveTo(x, y); g.lineTo(x + px, y); g.moveTo(x, y - 4); g.lineTo(x, y + 4);
g.moveTo(x + px, y - 4); g.lineTo(x + px, y + 4); g.stroke();
g.font = '11px -apple-system, Segoe UI, sans-serif';
g.textAlign = 'left'; g.textBaseline = 'bottom';
g.fillStyle = 'rgba(232,224,208,.8)';
g.fillText(`${m} m`, x + 2, y - 6);
}
// "segments", not "streets": `c.edges` counts plan edges, and a real street is many of them. When
// Lane A's address layer is wired we can say how many STREETS that is, so we do — same field, two
// suppliers, no town-type test (39.1's contract).
function refreshCaption(c) {
let streets = null;
if (addresses && typeof addresses.streetOf === 'function') {
const names = new Set();
for (const id of known.edgeIds()) { let n = null; try { n = addresses.streetOf(id); } catch (e) { names.clear(); break; } if (n) names.add(n); }
if (names.size) streets = names.size;
}
cap.textContent = `${plan.name} — seed ${plan.citySeed} · ${c.shops} shop${c.shops === 1 ? '' : 's'} known · `
+ (streets != null ? `${streets} street${streets === 1 ? '' : 's'}` : `${c.edges} street segment${c.edges === 1 ? '' : 's'}`)
+ ` walked · press M to close`;
}
function draw(playerPos, fwd) {
if (!known) drawStatic();
else {
const box = knownBox(playerPos.x, playerPos.z);
const v = known.version();
const key = `${v}|${box.x0},${box.x1},${box.z0},${box.z1}`;
if (key !== cacheKey) {
paintFog(box);
cacheKey = key;
const c = known.counts();
if (!lastCounts || c.shops !== lastCounts.shops || c.edges !== lastCounts.edges) { refreshCaption(c); lastCounts = c; }
}
ctx.clearRect(0, 0, W, H);
ctx.drawImage(off, 0, 0);
}
const x = T.tx(playerPos.x), z = T.tz(playerPos.z);
// heading (world +X→right, +Z→down on the map)
ctx.strokeStyle = '#ffd75e'; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(x, z);
ctx.lineTo(x + fwd.x * 16, z + fwd.z * 16); ctx.stroke();
ctx.fillStyle = '#ffd75e';
ctx.beginPath(); ctx.arc(x, z, 6, 0, Math.PI * 2); ctx.fill();
}
return {
draw,
setVisible: (v) => { wrap.style.display = v ? 'flex' : 'none'; },
dispose: () => wrap.remove(),
// [R39] Lane A's address layer, injectable — the shell wires it at boot when citygen exports
// createAddresses, and it can land mid-session without a reload.
setAddresses(a) { addresses = a || null; cacheKey = null; lastCounts = null; },
// what the map is actually showing, for the gates: metres across, and the px a shop lot renders at
get view() {
const med = (arr) => { const b = arr.slice().sort((p, q) => p - q); return b.length ? b[b.length >> 1] : 0; };
const shopLots = plan.shops.map((s) => plan.lots.find((l) => l.id === s.lot)).filter(Boolean);
const w = med(shopLots.map((l) => l.w)), d = med(shopLots.map((l) => l.d));
return { fog: !!known, spanFull: span, span: (W - pad * 2) / T.sc, scale: T.sc,
lotPx: { w: +(w * T.sc).toFixed(2), d: +(d * T.sc).toFixed(2) },
lotPxFull: { w: +(w * sc).toFixed(2), d: +(d * sc).toFixed(2) },
labels: !!addresses };
},
};
}