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>
338 lines
16 KiB
HTML
338 lines
16 KiB
HTML
<!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=>({'&':'&','<':'<','>':'>'}[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>
|