PROCITY/web/map.html
m3ultra 8b7ca9aae4 Lane A (CityGen): deterministic town generator, registry, names, 2D map, self-check
Layer-1 CityPlan generator per CITY_SPEC schema v1 — pure data, no THREE, all randomness
through core/prng, deterministic + JSON-serializable + <1ms.

- web/js/citygen/plan.js: generatePlan(citySeed) → CityPlan (spine, cross-streets, market
  square + dept anchor, arcade, warehouse fringe, residential collar w/ corner milkbars,
  laneways) + chunkIndex (Amanatides–Woo edge supercover) + corner overlap-resolution pass.
- web/js/core/registry.js: SHOP_TYPES (all 9) + district weights + enums (shared vocabulary).
- web/js/citygen/names.js + wordlists.js: seeded 90s-AU shop/town names, 50+ patterns.
- web/js/citygen/index.js: canonical barrel entry (adopts Lane F's integration shim).
- web/map.html: standalone Canvas-2D plan debugger (pan/zoom/hover, layers, export JSON).
- web/js/citygen/selfcheck.js: node acceptance harness — determinism, golden fingerprint,
  facing, within- AND cross-block no-overlap, chunk+edge coverage, finiteness, assets. ALL GREEN
  (1271/1271) over 6 seeds. web/package.json ({"type":"module"}) lets node run it.
- docs/shots/laneA/: 5 seeds screenshotted, each reads as a town.

Fixes from an adversarial multi-agent review (15 confirmed defects): market facades faced the
wrong way; cross-block corner overlaps; chunkIndex skipped chunks incl. the origin/spine;
stall frontEdge z-band; doubled-possessive names; unparseable map road colour; +others.

CITY_SPEC amendment (treaty, same commit): shop-types table aligned to the registry lanes
import; documented lot.ry/frontEdge, block.kind/district-id, and web/package.json.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:06:41 +10:00

338 lines
16 KiB
HTML
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.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PROCITY — plan debugger (Lane A)</title>
<style>
:root{
--bg:#14110d; --panel:#1c1813; --ink:#efe6d2; --dim:#a99a80; --line:#2c2519;
--road:#3a3020; --roadmain:#5a4a2e; --roadarc:#4a3a52; --roadlane:#2a241a;
--block:#241d14; --accent:#e0a44a;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:var(--bg);color:var(--ink);
font:13px/1.4 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;overflow:hidden}
#wrap{display:flex;height:100%}
#stage{flex:1;position:relative}
canvas{position:absolute;inset:0;width:100%;height:100%;display:block;cursor:crosshair}
#side{width:270px;background:var(--panel);border-left:1px solid var(--line);
padding:14px;overflow-y:auto;display:flex;flex-direction:column;gap:12px}
h1{font-size:15px;margin:0;letter-spacing:.06em;color:var(--accent)}
h1 small{display:block;color:var(--dim);font-size:11px;letter-spacing:.02em;margin-top:2px}
.row{display:flex;gap:6px;align-items:center}
input,button{font:inherit;background:#0e0b07;color:var(--ink);border:1px solid var(--line);
border-radius:6px;padding:6px 8px}
input{flex:1;min-width:0}
button{cursor:pointer;background:#2a2216}
button:hover{background:#37301f;border-color:var(--accent)}
button.primary{background:var(--accent);color:#1a1206;border-color:var(--accent);font-weight:700}
.stat{color:var(--dim)} .stat b{color:var(--ink);font-weight:700}
.legend{display:grid;grid-template-columns:auto 1fr;gap:3px 8px;align-items:center;font-size:11px}
.sw{width:12px;height:12px;border-radius:3px;border:1px solid #0006}
.muted{color:var(--dim);font-size:11px}
#tip{position:absolute;pointer-events:none;background:#000c;border:1px solid var(--accent);
color:var(--ink);padding:5px 8px;border-radius:6px;font-size:12px;max-width:240px;
transform:translate(10px,10px);display:none;z-index:5;white-space:nowrap}
#tip .t{color:var(--accent);font-weight:700}
#tip .s{color:var(--dim);font-size:11px}
hr{border:0;border-top:1px solid var(--line);margin:2px 0}
.toggles{display:grid;grid-template-columns:1fr 1fr;gap:4px}
label.chk{display:flex;gap:5px;align-items:center;font-size:11px;color:var(--dim);cursor:pointer}
</style>
</head>
<body>
<div id="wrap">
<div id="stage">
<canvas id="c"></canvas>
<div id="tip"></div>
</div>
<aside id="side">
<h1>PROCITY<small>plan debugger · Lane A</small></h1>
<div class="row">
<input id="seed" value="20261990" inputmode="numeric" title="city seed (uint32)">
<button id="regen" class="primary">regen</button>
</div>
<div class="row">
<button id="rand" style="flex:1">🎲 random</button>
<button id="export" style="flex:1">⤓ JSON</button>
</div>
<div id="name" class="stat"></div>
<div id="stats" class="stat"></div>
<hr>
<div class="muted">layers</div>
<div class="toggles">
<label class="chk"><input type="checkbox" id="tChunks"> chunk grid</label>
<label class="chk"><input type="checkbox" id="tBlocks" checked> blocks</label>
<label class="chk"><input type="checkbox" id="tLots" checked> lots</label>
<label class="chk"><input type="checkbox" id="tShops" checked> shop dots</label>
<label class="chk"><input type="checkbox" id="tLabels"> all labels</label>
<label class="chk"><input type="checkbox" id="tNodes"> nodes</label>
</div>
<hr>
<div class="muted">shop types</div>
<div class="legend" id="legend"></div>
<hr>
<div class="muted">lot use</div>
<div class="legend" id="legendUse"></div>
<hr>
<div class="muted" style="line-height:1.6">
drag to pan · scroll to zoom · hover a shop for its name.<br>
This is Lane A's debug view — it grows into the in-game map.
</div>
</aside>
</div>
<!-- No importmap / no THREE: this debug page is pure Canvas 2D (CITY_SPEC allows the map page to
use THREE, but Lane A's 2D map doesn't need it). It imports only Lane A's pure-data modules. -->
<script type="module">
import { generatePlan, chunkIndex, CHUNK } from './js/citygen/plan.js';
import { SHOP_TYPES, SHOP_TYPE_IDS } from './js/core/registry.js';
// ── palette ─────────────────────────────────────────────────────────────────────────
const TYPE_COLOR = {
record:'#ff5470', opshop:'#5fbf74', toy:'#ffcf3f', book:'#c98b3b', video:'#ffe14d',
pawn:'#c0c0c0', milkbar:'#ff6b6b', dept:'#e07b9a', stall:'#c8a24a',
};
const USE_COLOR = {
shop:'#6b5a3a', anchor:'#8a4a63', house:'#3f5a4a', yard:'#2e3a2a', stall:'#7a6a3a', infill:'#4a4436',
};
const EDGE_STYLE = { // c = bare CSS custom-prop name OR hex — getcss() resolves either (never a var() string,
main:{c:'--roadmain', w:1}, side:{c:'#4a3f28', w:1}, lane:{c:'#2a241a', w:1}, arcade:{c:'#6a4a72', w:1}, // which canvas can't parse)
};
// ── state ───────────────────────────────────────────────────────────────────────────
const cv = document.getElementById('c'), ctx = cv.getContext('2d');
let plan = null, idx = null, DPR = Math.min(devicePixelRatio||1, 2);
const view = { x:0, z:0, scale:0.6 }; // world→screen: screen = (world - viewcenter)*scale + canvascenter
let hover = null;
const $ = id => document.getElementById(id);
const cssPx = () => ({ w: cv.clientWidth, h: cv.clientHeight });
function resize(){
const {w,h} = cssPx();
cv.width = w*DPR; cv.height = h*DPR;
draw();
}
function fitView(){
const {w,h} = cssPx();
const s = plan.size;
view.x = 0; view.z = 0;
view.scale = Math.min(w/(s.w*1.08), h/(s.d*1.08));
}
const w2s = (x,z) => {
const {w,h} = cssPx();
return [ (x-view.x)*view.scale + w/2, (z-view.z)*view.scale + h/2 ];
};
const s2w = (sx,sz) => {
const {w,h} = cssPx();
return [ (sx-w/2)/view.scale + view.x, (sz-h/2)/view.scale + view.z ];
};
// ── draw ──────────────────────────────────────────────────────────────────────────
function draw(){
if(!plan) return;
const {w,h}=cssPx();
ctx.setTransform(DPR,0,0,DPR,0,0);
ctx.clearRect(0,0,w,h);
ctx.fillStyle = getcss('--bg'); ctx.fillRect(0,0,w,h);
// town bounds
const s=plan.size;
const [bx0,bz0]=w2s(-s.w/2,-s.d/2), [bx1,bz1]=w2s(s.w/2,s.d/2);
ctx.fillStyle='#0e0b07'; ctx.fillRect(bx0,bz0,bx1-bx0,bz1-bz0);
ctx.strokeStyle=getcss('--line'); ctx.lineWidth=1; ctx.strokeRect(bx0,bz0,bx1-bx0,bz1-bz0);
if ($('tChunks').checked) drawChunks();
if ($('tBlocks').checked) drawBlocks();
drawStreets();
if ($('tLots').checked) drawLots();
if ($('tShops').checked) drawShops();
if ($('tNodes').checked) drawNodes();
if (hover) drawHoverHi();
drawCompass();
}
let _drawQueued=false;
function scheduleDraw(){ if(_drawQueued) return; _drawQueued=true; requestAnimationFrame(()=>{ _drawQueued=false; draw(); }); }
function getcss(v){ return getComputedStyle(document.documentElement).getPropertyValue(v).trim() || v; }
function drawChunks(){
const s=plan.size, c=CHUNK;
ctx.strokeStyle='#ffffff10'; ctx.lineWidth=1;
for(let x=-s.w/2; x<=s.w/2; x+=c){ const [sx0,sy0]=w2s(x,-s.d/2),[sx1,sy1]=w2s(x,s.d/2);
ctx.beginPath();ctx.moveTo(sx0,sy0);ctx.lineTo(sx1,sy1);ctx.stroke(); }
for(let z=-s.d/2; z<=s.d/2; z+=c){ const [sx0,sy0]=w2s(-s.w/2,z),[sx1,sy1]=w2s(s.w/2,z);
ctx.beginPath();ctx.moveTo(sx0,sy0);ctx.lineTo(sx1,sy1);ctx.stroke(); }
}
function drawBlocks(){
for(const b of plan.blocks){
ctx.beginPath();
b.poly.forEach((p,i)=>{ const [sx,sy]=w2s(p[0],p[1]); i?ctx.lineTo(sx,sy):ctx.moveTo(sx,sy); });
ctx.closePath();
ctx.fillStyle = DISTRICT_FILL[b.kind] || '#241d1440';
ctx.fill();
ctx.strokeStyle='#00000030'; ctx.lineWidth=1; ctx.stroke();
}
}
const DISTRICT_FILL = {
mainstreet:'#2c2414aa', backstreets:'#241f16aa', arcade:'#2e2338aa',
market:'#2c2a16aa', warehouse:'#20211caa', residential:'#1c2620aa',
};
function drawStreets(){
const nById = new Map(plan.streets.nodes.map(n=>[n.id,n]));
// widen roads with the edge width, scaled
for(const e of plan.streets.edges){
const a=nById.get(e.a), b=nById.get(e.b); if(!a||!b) continue;
const [ax,ay]=w2s(a.x,a.z), [bx,by]=w2s(b.x,b.z);
const st = EDGE_STYLE[e.kind]||EDGE_STYLE.side;
ctx.strokeStyle = getcss(st.c);
ctx.lineWidth = Math.max(1, e.width*view.scale);
ctx.lineCap='round';
ctx.beginPath();ctx.moveTo(ax,ay);ctx.lineTo(bx,by);ctx.stroke();
}
}
function lotQuad(l){
const cs=Math.cos(l.ry), sn=Math.sin(l.ry), hw=l.w/2, hd=l.d/2;
return [[-hw,-hd],[hw,-hd],[hw,hd],[-hw,hd]].map(([ox,oz])=>[l.x+ox*cs+oz*sn, l.z-ox*sn+oz*cs]);
}
function drawLots(){
for(const l of plan.lots){
const q=lotQuad(l);
ctx.beginPath();
q.forEach((p,i)=>{ const [sx,sy]=w2s(p[0],p[1]); i?ctx.lineTo(sx,sy):ctx.moveTo(sx,sy); });
ctx.closePath();
ctx.fillStyle=USE_COLOR[l.use]||'#333'; ctx.fill();
ctx.strokeStyle='#0008'; ctx.lineWidth=.5; ctx.stroke();
}
}
let _sbl=null; // shop-by-lot cache, module-level (NEVER attached to plan — export must stay schema-clean)
function shopByLot(){ return _sbl || (_sbl=new Map(plan.shops.map(s=>[s.lot,s]))); }
function drawShops(){
const sbl=shopByLot();
const lById=new Map(plan.lots.map(l=>[l.id,l]));
const showLabels=$('tLabels').checked;
ctx.textAlign='center'; ctx.textBaseline='middle';
for(const sh of plan.shops){
const l=lById.get(sh.lot); if(!l) continue;
const [sx,sy]=w2s(l.x,l.z);
const rr=Math.max(2, Math.min(6, l.w*view.scale*0.35));
ctx.beginPath();ctx.arc(sx,sy,rr,0,7); ctx.fillStyle=TYPE_COLOR[sh.type]||'#fff'; ctx.fill();
ctx.lineWidth=1; ctx.strokeStyle='#0009'; ctx.stroke();
if(showLabels && view.scale>0.8){
ctx.font='9px ui-monospace,monospace'; ctx.fillStyle='#efe6d2cc';
ctx.fillText(sh.sign, sx, sy-rr-6);
}
}
}
function drawNodes(){
ctx.fillStyle=getcss('--accent');
for(const n of plan.streets.nodes){ const [sx,sy]=w2s(n.x,n.z);
ctx.beginPath();ctx.arc(sx,sy,2,0,7);ctx.fill(); }
}
function drawHoverHi(){
const l=hover.lot; const q=lotQuad(l);
ctx.beginPath(); q.forEach((p,i)=>{const [sx,sy]=w2s(p[0],p[1]); i?ctx.lineTo(sx,sy):ctx.moveTo(sx,sy);});
ctx.closePath(); ctx.strokeStyle=getcss('--accent'); ctx.lineWidth=2; ctx.stroke();
}
function drawCompass(){
const {w}=cssPx(); const x=w-38, y=34;
ctx.strokeStyle=getcss('--dim'); ctx.fillStyle=getcss('--dim'); ctx.lineWidth=1.5;
ctx.beginPath();ctx.moveTo(x,y+12);ctx.lineTo(x,y-12);ctx.stroke();
ctx.beginPath();ctx.moveTo(x,y-12);ctx.lineTo(x-3,y-7);ctx.lineTo(x+3,y-7);ctx.closePath();ctx.fill();
ctx.font='10px monospace';ctx.textAlign='center';ctx.fillText('N',x,y-16);
}
// ── picking ─────────────────────────────────────────────────────────────────────────
function pick(sx,sz){
const [wx,wz]=s2w(sx,sz);
const sbl=shopByLot();
let best=null, bestD=Infinity;
for(const l of plan.lots){
const d=(l.x-wx)**2+(l.z-wz)**2;
if(d<bestD && d < Math.max(l.w,l.d)**2){ bestD=d; best=l; }
}
if(!best) return null;
return { lot:best, shop:sbl.get(best.id)||null };
}
// ── interaction ───────────────────────────────────────────────────────────────────
let drag=null;
cv.addEventListener('mousedown', e=>{ drag={x:e.clientX,y:e.clientY,vx:view.x,vz:view.z}; });
addEventListener('mouseup', ()=> drag=null);
addEventListener('mousemove', e=>{
const rect=cv.getBoundingClientRect(); const sx=e.clientX-rect.left, sy=e.clientY-rect.top;
if(drag){ view.x=drag.vx-(e.clientX-drag.x)/view.scale; view.z=drag.vz-(e.clientY-drag.y)/view.scale; scheduleDraw(); return; }
if(sx<0||sy<0||sx>rect.width||sy>rect.height){ hideTip(); return; }
hover=pick(sx,sy);
const tip=$('tip');
if(hover){
cv.style.cursor='pointer';
const l=hover.lot, s=hover.shop;
tip.style.display='block'; tip.style.left=sx+'px'; tip.style.top=sy+'px';
tip.innerHTML = s
? `<div class="t">${esc(s.name)}</div><div class="s">${s.type} · ${SHOP_TYPES[s.type]?.label||''} · ${s.storeys}fl · ${pad(s.hours[0])}${pad(s.hours[1])}</div><div class="s">${s.facadeSkin}</div>`
: `<div class="t">${l.use} lot</div><div class="s">${l.w}×${l.d}m · block ${l.block}</div>`;
scheduleDraw();
} else { hideTip(); }
});
function hideTip(){ if(hover){hover=null;scheduleDraw();} $('tip').style.display='none'; cv.style.cursor='crosshair'; }
cv.addEventListener('wheel', e=>{
e.preventDefault();
const rect=cv.getBoundingClientRect(); const sx=e.clientX-rect.left, sy=e.clientY-rect.top;
const [wx,wz]=s2w(sx,sy);
const f=Math.exp(-e.deltaY*0.0012);
view.scale=Math.max(0.05, Math.min(8, view.scale*f));
const [nx,nz]=s2w(sx,sy); view.x+=wx-nx; view.z+=wz-nz;
scheduleDraw();
}, {passive:false});
const esc=s=>String(s).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
const pad=h=>String(h).padStart(2,'0')+':00';
// ── panel ─────────────────────────────────────────────────────────────────────────
function regen(seed){
const s = (seed>>>0);
plan = generatePlan(s); idx = chunkIndex(plan); _sbl = null; // rebuild the cache for the new plan
$('seed').value = s;
$('name').innerHTML = `<b style="color:var(--accent);font-size:14px">${esc(plan.name)}</b>`;
const nShop=plan.shops.length, byType={};
for(const sh of plan.shops) byType[sh.type]=(byType[sh.type]||0)+1;
$('stats').innerHTML =
`<b>${plan.streets.edges.length}</b> streets · <b>${plan.blocks.length}</b> blocks<br>`+
`<b>${plan.lots.length}</b> lots · <b>${nShop}</b> shops · <b>${Object.keys(idx.chunks).length}</b> chunks`;
fitView(); draw(); buildLegend(byType);
}
function buildLegend(byType){
const L=$('legend'); L.innerHTML='';
for(const t of SHOP_TYPE_IDS){
const sw=document.createElement('span'); sw.className='sw'; sw.style.background=TYPE_COLOR[t];
const lb=document.createElement('span'); lb.textContent=`${t} (${byType?.[t]||0})`;
L.append(sw,lb);
}
const U=$('legendUse'); U.innerHTML='';
for(const u of Object.keys(USE_COLOR)){
const sw=document.createElement('span'); sw.className='sw'; sw.style.background=USE_COLOR[u];
const lb=document.createElement('span'); lb.textContent=u;
U.append(sw,lb);
}
}
$('regen').onclick=()=>regen(parseInt($('seed').value,10)||0);
$('seed').addEventListener('keydown',e=>{ if(e.key==='Enter') $('regen').click(); });
$('rand').onclick=()=>regen((Math.random()*4294967295)>>>0); // UI-only randomness; generation stays seeded
$('export').onclick=()=>{
const blob=new Blob([JSON.stringify(plan,null,0)],{type:'application/json'});
const a=document.createElement('a'); a.href=URL.createObjectURL(blob);
a.download=`procity-${plan.citySeed}.json`; a.click(); URL.revokeObjectURL(a.href);
};
for(const id of ['tChunks','tBlocks','tLots','tShops','tLabels','tNodes']) $(id).onchange=draw;
addEventListener('resize', resize);
// boot — seed can come from ?seed=… (Lane F / screenshot harness).
// rAF ensures the flex layout has sized the canvas before fitView reads its dimensions.
const q=new URLSearchParams(location.search);
const bootSeed=parseInt(q.get('seed')||$('seed').value,10)||20261990;
requestAnimationFrame(()=>{ resize(); regen(bootSeed); });
</script>
</body>
</html>