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>
309 lines
16 KiB
JavaScript
309 lines
16 KiB
JavaScript
// 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 };
|
||
},
|
||
};
|
||
}
|