/* ZHILA · the quarry a core is a place, a face is a cut, a ring is a year. the choir goes out and takes; the hand cuts and sets. */ (function () { 'use strict'; const $ = (s, r = document) => r.querySelector(s); const $$ = (s, r = document) => Array.prototype.slice.call(r.querySelectorAll(s)); const clamp = (v, a, b) => v < a ? a : v > b ? b : v; const TAU = Math.PI * 2; const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); const tray = $('#tray'); const tctx = tray.getContext('2d'); const overlay = $('#tray-overlay'); const stage = $('#tray-stage'); const wall = $('#face-wall'); const storeEl = $('#store'); const ledgerEl = $('#ledger'); const E = window.ZHILA_ENGINE; const cores = E ? E.cores : []; const SEMIS = [-12, -9, -7, -5, 0, 2, 3, 5, 7, 8, 10, 12, 14, 15, 17, 19]; const state = { sel: 0, lens: 0.84, fracture: 0, faceSeed: 7, selFace: -1, rings: 4, ghost: true, weather: { age: 0.34, pressure: 0.52, air: 0.41, heat: 0.28 } }; const HAND_BANK = ['underpass, 2am', 'wet brick', 'empty pool', 'ventilation shaft', 'far room', 'snow on tin', 'lift cable']; let handBank = HAND_BANK.slice(); let lensLine = null, lensHandle = null, lensText = null; /* ————— cores ————— */ function mulberry(seed) { let a = seed | 0; return function () { a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; } /* ————— ledger ————— */ let entries = E ? E.log : []; function sealHTML(kind, small) { if (!kind) return ''; return '' + kind + ''; } function renderLedger() { entries = E ? E.log : entries; ledgerEl.innerHTML = entries.slice(0, 3).map(function (e) { return '
  • ' + '
    ' + e.t + '' + '' + esc(e.tool) + '' + '' + e.by + '
    ' + '
    ' + esc(e.detail) + ' ' + sealHTML(e.seal) + '
    ' + '
  • '; }).join(''); } function nowTime() { const d = new Date(); return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0'); } function log(tool, detail, seal, by) { if (E) E.addLog(tool, detail, seal, by); } /* ————— core store ————— */ function drawThumb(canvas, core) { const g = canvas.getContext('2d'); const W = canvas.width, H = canvas.height; g.clearRect(0, 0, W, H); const x = W * 0.18, y = H * 0.04, w = W * 0.64, h = H * 0.92, r = w / 2; const p = new Path2D(); p.moveTo(x + r, y); p.lineTo(x + w - r, y); p.arc(x + w - r, y + r, r, -Math.PI / 2, Math.PI / 2); p.lineTo(x + r, y + h); p.arc(x + r, y + h - r, r, Math.PI / 2, Math.PI * 1.5); p.closePath(); g.save(); g.clip(p); const grd = g.createLinearGradient(x, y, x + w, y); grd.addColorStop(0, '#1a0d0a'); grd.addColorStop(0.45, '#2b1611'); grd.addColorStop(1, '#0b0605'); g.fillStyle = grd; g.fillRect(x, y, w, h); const rnd = mulberry(core.seed * 31 + 3); for (let i = 0; i < 8; i++) { const yy = y + h * ((i + 0.6) / 8.6); const amp = 1 + rnd() * 3; g.beginPath(); for (let k = 0; k <= 10; k++) { const u = k / 10; const px = x + u * w; const py = yy + Math.sin(u * 6 + i) * amp; if (k === 0) g.moveTo(px, py); else g.lineTo(px, py); } g.strokeStyle = i % 2 ? 'rgba(190,130,105,.16)' : 'rgba(80,45,35,.5)'; g.lineWidth = 1; g.stroke(); } // the seam of sound g.beginPath(); for (let i = 0; i < core.env.length; i++) { const px = x + (i / (core.env.length - 1)) * w; const py = y + h / 2 - core.env[i] * h * 0.3; if (i === 0) g.moveTo(px, py); else g.lineTo(px, py); } g.strokeStyle = 'rgba(255,105,75,.55)'; g.lineWidth = 1; g.stroke(); g.restore(); g.strokeStyle = 'rgba(239,228,212,.16)'; g.lineWidth = 1; g.stroke(p); g.beginPath(); g.ellipse(x + r, y + h / 2, r * 0.55, h * 0.3, 0, 0, TAU); g.strokeStyle = 'rgba(239,228,212,.1)'; g.stroke(); } function buildStore() { storeEl.innerHTML = ''; cores.forEach(function (core, i) { const item = document.createElement('button'); item.type = 'button'; item.className = 'core-item' + (i === state.sel ? ' is-on' : ''); item.dataset.core = i; item.innerHTML = '' + '' + '' + esc(core.name) + '' + '' + core.dur.toFixed(1) + ' s ' + sealHTML(core.seal) + '' + ''; storeEl.appendChild(item); drawThumb(item.querySelector('canvas'), core); }); } storeEl.addEventListener('click', function (e) { const item = e.target.closest('.core-item'); if (!item) return; selectCore(+item.dataset.core); }); function highlightStore() { $$('.core-item', storeEl).forEach(function (el, i) { const on = i === state.sel; el.classList.toggle('is-on', on); if (on && el.scrollIntoView) el.scrollIntoView({ block: 'nearest' }); }); } /* ————— tray ————— */ function trayGeom() { const W = stage.clientWidth, H = stage.clientHeight; const coreH = clamp(H * 0.55, 96, 172); const top = Math.max(36, (H - coreH) / 2 - 10); const pad = 24; return { W: W, H: H, x0: pad + 10, x1: W - pad - 10, top: top, bottom: top + coreH, coreH: coreH }; } function capsule(g, x0, top, x1, bottom) { const p = new Path2D(); if (p.roundRect) p.roundRect(x0, top, x1 - x0, bottom - top, 2.5); else p.rect(x0, top, x1 - x0, bottom - top); return p; } function drawTray() { const g0 = trayGeom(); if (!g0.W || !g0.H) return; const dpr = Math.min(2, window.devicePixelRatio || 1); tray.width = Math.round(g0.W * dpr); tray.height = Math.round(g0.H * dpr); tctx.setTransform(dpr, 0, 0, dpr, 0, 0); tctx.clearRect(0, 0, g0.W, g0.H); const core = cores[state.sel]; const { x0, x1, top, bottom, coreH } = g0; const mid = (top + bottom) / 2; const path = capsule(tctx, x0, top, x1, bottom); const rnd = mulberry(core.seed * 131 + 7); // shadow under the core tctx.save(); tctx.filter = 'blur(14px)'; tctx.fillStyle = 'rgba(0,0,0,.75)'; tctx.beginPath(); tctx.ellipse((x0 + x1) / 2, bottom + 10, (x1 - x0) * 0.48, coreH * 0.22, 0, 0, TAU); tctx.fill(); tctx.restore(); tctx.save(); tctx.clip(path); const grd = tctx.createLinearGradient(0, top, 0, bottom); grd.addColorStop(0, '#26130d'); grd.addColorStop(0.16, '#170b08'); grd.addColorStop(0.55, '#100705'); grd.addColorStop(1, '#040202'); tctx.fillStyle = grd; tctx.fillRect(x0, top, x1 - x0, coreH); // strata: sediment layers const bands = 10; for (let i = 0; i < bands; i++) { const yy = top + coreH * ((i + 0.55) / (bands + 0.9)) + (rnd() - 0.5) * 4; const amp = 2 + rnd() * 7; const freq = 1.2 + rnd() * 2.4; const phase = rnd() * TAU; const pts = []; for (let k = 0; k <= 32; k++) { const u = k / 32; const px = x0 + u * (x1 - x0); const py = yy + Math.sin(u * TAU * freq + phase) * amp + Math.sin(u * TAU * 5.3 + phase * 2) * 1.4; pts.push([px, py]); } tctx.beginPath(); pts.forEach(function (p, k) { if (k === 0) tctx.moveTo(p[0], p[1]); else tctx.lineTo(p[0], p[1]); }); tctx.lineTo(x1, bottom); tctx.lineTo(x0, bottom); tctx.closePath(); tctx.fillStyle = i % 2 ? 'rgba(150,60,40,.05)' : 'rgba(0,0,0,.18)'; tctx.fill(); tctx.beginPath(); pts.forEach(function (p, k) { if (k === 0) tctx.moveTo(p[0], p[1]); else tctx.lineTo(p[0], p[1]); }); tctx.strokeStyle = i % 3 === 0 ? 'rgba(232,64,42,.16)' : 'rgba(190,130,105,.1)'; tctx.lineWidth = 0.8; tctx.stroke(); } // the seam of sound, inside the stone const env = core.env; const ampY = coreH * 0.3; if (state.ghost) { tctx.beginPath(); for (let i = 0; i < env.length; i++) { const px = x0 + (i / (env.length - 1)) * (x1 - x0); const py = mid - clamp(env[i] * 1.45, 0, 1) * ampY * 1.25; if (i === 0) tctx.moveTo(px, py); else tctx.lineTo(px, py); } tctx.strokeStyle = 'rgba(232,64,42,.14)'; tctx.lineWidth = 0.9; tctx.stroke(); } tctx.beginPath(); for (let i = 0; i < env.length; i++) { const px = x0 + (i / (env.length - 1)) * (x1 - x0); const py = mid - env[i] * ampY; if (i === 0) tctx.moveTo(px, py); else tctx.lineTo(px, py); } for (let i = env.length - 1; i >= 0; i--) { const px = x0 + (i / (env.length - 1)) * (x1 - x0); tctx.lineTo(px, mid + env[i] * ampY * 0.78); } tctx.closePath(); tctx.fillStyle = 'rgba(232,64,42,.13)'; tctx.fill(); tctx.save(); tctx.shadowColor = 'rgba(232,64,42,.75)'; tctx.shadowBlur = 14; tctx.beginPath(); for (let i = 0; i < env.length; i++) { const px = x0 + (i / (env.length - 1)) * (x1 - x0); const py = mid - env[i] * ampY; if (i === 0) tctx.moveTo(px, py); else tctx.lineTo(px, py); } tctx.strokeStyle = 'rgba(255,140,105,.95)'; tctx.lineWidth = 1.5; tctx.stroke(); tctx.restore(); // pits and hairlines in the stone for (let k = 0; k < 16; k++) { const px = x0 + rnd() * (x1 - x0); const py = top + rnd() * coreH; const rr = 0.7 + rnd() * 2.2; tctx.beginPath(); tctx.ellipse(px, py, rr * 1.9, rr, 0, 0, TAU); tctx.fillStyle = 'rgba(0,0,0,.4)'; tctx.fill(); tctx.beginPath(); tctx.ellipse(px, py + 0.7, rr * 1.5, rr * 0.55, 0, 0, TAU); tctx.strokeStyle = 'rgba(232,64,42,.16)'; tctx.lineWidth = 0.6; tctx.stroke(); } for (let k = 0; k < 3; k++) { let cx = x0 + (0.12 + rnd() * 0.74) * (x1 - x0); let cy = top + rnd() * coreH * 0.55; tctx.beginPath(); tctx.moveTo(cx, cy); for (let s2 = 0; s2 < 6; s2++) { cx += (rnd() - 0.5) * 12; cy += 4 + rnd() * 7; tctx.lineTo(cx, cy); } tctx.strokeStyle = 'rgba(0,0,0,.55)'; tctx.lineWidth = 1.2; tctx.stroke(); tctx.strokeStyle = 'rgba(232,64,42,.15)'; tctx.lineWidth = 0.5; tctx.stroke(); } // fractures, gashes in the core core.cuts.forEach(function (cut) { const x = x0 + clamp(cut / core.dur, 0, 1) * (x1 - x0); const jag = mulberry(Math.round(cut * 100) + core.seed); const pts = []; for (let y = top; y <= bottom; y += 7) pts.push([x + (jag() - 0.5) * 3.2, y]); tctx.beginPath(); pts.forEach(function (p, k) { if (k === 0) tctx.moveTo(p[0], p[1]); else tctx.lineTo(p[0], p[1]); }); tctx.strokeStyle = 'rgba(0,0,0,.8)'; tctx.lineWidth = 5; tctx.stroke(); tctx.strokeStyle = 'rgba(255,110,80,.62)'; tctx.lineWidth = 1.15; tctx.stroke(); }); // surface light const hi = tctx.createLinearGradient(0, top, 0, top + coreH * 0.3); hi.addColorStop(0, 'rgba(255,205,175,.1)'); hi.addColorStop(1, 'rgba(255,205,175,0)'); tctx.fillStyle = hi; tctx.fillRect(x0, top, x1 - x0, coreH * 0.3); for (let k = 0; k < 6; k++) { const cx = x0 + 8 + rnd() * (x1 - x0 - 16); const w = 3 + rnd() * 11; const d = 1 + rnd() * 3.4; tctx.beginPath(); tctx.moveTo(cx - w / 2, top); tctx.lineTo(cx + w / 2, top); tctx.lineTo(cx + (rnd() - 0.5) * 4, top + d); tctx.closePath(); tctx.fillStyle = 'rgba(2,1,1,.75)'; tctx.fill(); } tctx.restore(); tctx.strokeStyle = 'rgba(239,228,212,.2)'; tctx.lineWidth = 1; tctx.stroke(path); // the cut ends of the bore [x0 + 6, x1 - 6].forEach(function (cx) { tctx.beginPath(); tctx.ellipse(cx, mid, 6, coreH / 2 - 1, 0, 0, TAU); tctx.fillStyle = 'rgba(3,2,2,.55)'; tctx.fill(); tctx.strokeStyle = 'rgba(239,228,212,.2)'; tctx.lineWidth = 0.9; tctx.stroke(); tctx.beginPath(); tctx.ellipse(cx, mid, 2.4, coreH * 0.34, 0, 0, TAU); tctx.strokeStyle = 'rgba(239,228,212,.08)'; tctx.lineWidth = 0.7; tctx.stroke(); }); } function buildOverlay() { const g = trayGeom(); if (!g.W || !g.H) return; const core = cores[state.sel]; const { x0, x1, top, bottom } = g; let s = ''; s += ''; core.cuts.forEach(function (cut, i) { const x = x0 + clamp(cut / core.dur, 0, 1) * (x1 - x0); s += ''; s += '0' + (i + 1) + ''; }); const ry = bottom + 30; s += ''; const step = core.dur > 16 ? 4 : core.dur > 10 ? 2 : 1; for (let t = 0; t <= core.dur + 0.01; t += step) { const x = x0 + clamp(t / core.dur, 0, 1) * (x1 - x0); const major = t % (step * 2) === 0; s += ''; if (major) { s += '' + t.toFixed(0) + 's'; } } s += '' + '' + '' + '' + ''; overlay.setAttribute('viewBox', '0 0 ' + g.W + ' ' + g.H); overlay.innerHTML = s; lensLine = overlay.querySelector('.tray-lens'); lensHandle = overlay.querySelector('.tray-lens-handle'); lensText = overlay.querySelector('.tray-lens-text'); updateLensOnly(); } function updateLensOnly() { if (!lensLine || !lensText) return; const g = trayGeom(); if (!g.W) return; const core = cores[state.sel]; const lx = g.x0 + clamp(state.lens / core.dur, 0, 1) * (g.x1 - g.x0); lensLine.setAttribute('x1', lx.toFixed(1)); lensLine.setAttribute('x2', lx.toFixed(1)); lensLine.setAttribute('y1', (g.top - 16).toFixed(1)); lensLine.setAttribute('y2', (g.bottom + 16).toFixed(1)); lensHandle.setAttribute('x', (lx - 2).toFixed(1)); lensHandle.setAttribute('y', (g.top - 24).toFixed(1)); lensText.setAttribute('x', Math.min(lx + 8, g.x1 - 36).toFixed(1)); lensText.setAttribute('y', (g.top - 12).toFixed(1)); lensText.textContent = state.lens.toFixed(2) + 's'; const pos = $('#tray-pos'); if (pos) pos.textContent = state.lens.toFixed(2) + ' s'; } /* ————— faces ————— */ function stoneShape(cell, rnd) { const inset = 1.4; const x0 = cell.x + inset, y0 = cell.y + inset; const x1 = cell.x + cell.w - inset, y1 = cell.y + cell.h - inset; const corners = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]]; const pts = []; for (let e = 0; e < 4; e++) { const a = corners[e], b = corners[(e + 1) % 4]; pts.push([a[0] + (rnd() - 0.5) * 2.5, a[1] + (rnd() - 0.5) * 2.5]); pts.push([ (a[0] + b[0]) / 2 + (rnd() - 0.5) * 9, (a[1] + b[1]) / 2 + (rnd() - 0.5) * 9 ]); } return pts; } function centroid(pts) { const n = pts.length; return [pts.reduce((a, p) => a + p[0], 0) / n, pts.reduce((a, p) => a + p[1], 0) / n]; } function ptsStr(pts) { return pts.map(function (p) { return p[0].toFixed(1) + ',' + p[1].toFixed(1); }).join(' '); } function sealMark(x, y, kind) { let inner = ''; if (kind === 'taken') inner = ''; else if (kind === 'found') inner = ''; else if (kind === 'loan') inner = ''; else if (kind === 'gift') inner = ''; return '' + inner; } function buildFaces(seed) { if (!wall || !wall.clientWidth || !wall.clientHeight) return; state.faceSeed = seed; const W = wall.clientWidth, H = wall.clientHeight; const core = cores[state.sel]; const rnd = mulberry(seed); const rows = 4, gap = 8; let hs = [0, 0, 0, 0].map(function () { return 0.78 + rnd() * 0.44; }); const hsum = hs.reduce(function (a, b) { return a + b; }, 0); const availH = H - gap * (rows - 1) - 2; let y = 1; const rowRects = []; hs.forEach(function (h) { const hh = availH * h / hsum; rowRects.push({ y: y, h: hh }); y += hh + gap; }); const faces = []; let idx = 0; let svg = ''; ['A', 'B', 'C', 'D'].forEach(function (t, ti) { svg += '' + '' + '' + '' + ''; }); svg += ''; rowRects.forEach(function (row, r) { let ws = [0, 0, 0, 0].map(function () { return 0.72 + rnd() * 0.56; }); const wsum = ws.reduce(function (a, b) { return a + b; }, 0); const availW = W - gap * 3 - 2; let x = 1; ws.forEach(function (w, c) { const ww = availW * w / wsum; const cell = { x: x, y: row.y, w: ww, h: row.h }; const pts = stoneShape(cell, rnd); const cen = centroid(pts); const inner = pts.map(function (p) { return [p[0] + (cen[0] - p[0]) * 0.09, p[1] + (cen[1] - p[1]) * 0.09]; }); const inner2 = pts.map(function (p) { return [p[0] + (cen[0] - p[0]) * 0.2, p[1] + (cen[1] - p[1]) * 0.2]; }); const tone = ['a', 'b', 'c', 'd'][Math.floor(rnd() * 4)]; const lip = [pts[7], pts[0], pts[1], pts[2]]; const foot = [pts[3], pts[4], pts[5], pts[6]]; const grain = []; const gy = cell.y + cell.h * 0.58; for (let k = 0; k <= 8; k++) { const u = k / 8; grain.push([cell.x + 7 + u * (cell.w - 14), gy + Math.sin(u * 5 + rnd() * 2) * 3.4]); } const grain2 = []; const gy2 = cell.y + cell.h * 0.79; for (let k = 0; k <= 6; k++) { const u = k / 6; grain2.push([cell.x + 11 + u * (cell.w - 22), gy2 + Math.sin(u * 4 + rnd() * 2) * 2.6]); } const crack = []; let cx = cell.x + cell.w * (0.45 + rnd() * 0.3), cy = cell.y + 6; crack.push([cx, cy]); for (let k = 0; k < 4; k++) { cx += (rnd() - 0.5) * 12; cy += 4 + rnd() * 8; crack.push([cx, cy]); } const pits = [0, 1, 2].map(function () { return [cell.x + 12 + rnd() * (cell.w - 24), cell.y + 12 + rnd() * (cell.h - 24), 0.7 + rnd() * 1.3]; }); const strata = []; for (let k = 0; k <= 6; k++) { const u = k / 6; strata.push([cell.x + 8 + u * (cell.w - 16), cell.y + cell.h * 0.32 + Math.sin(u * 4 + r) * 2.4]); } const num = String(idx + 1).padStart(2, '0'); const slice = core.faces[idx]; const face = { i: idx, semis: SEMIS[(3 - r) * 4 + c], seal: core.seal, t0: slice ? +slice.start.toFixed(2) : 0, t1: slice ? +(slice.start + slice.len).toFixed(2) : 0 }; faces.push(face); const isSel = state.selFace === idx; svg += '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + pits.map(function (p) { return ''; }).join('') + '' + num + '' + sealMark(cell.x + cell.w - 14, cell.y + cell.h - 13, core.seal) + ''; idx++; x += ww + gap; }); }); svg += ''; wall.innerHTML = svg; state.faces = faces; // keep the selection valid if (state.selFace >= faces.length) state.selFace = -1; } function indexDur(i) { const core = cores[state.sel]; return core.dur / 16; } wall.addEventListener('pointerdown', function (e) { const g = e.target.closest ? e.target.closest('.stone') : null; if (!g) return; const i = +g.dataset.i; if (state.selFace !== i) { state.selFace = i; $$('.stone', wall).forEach(function (el) { el.classList.toggle('is-sel', +el.dataset.i === i); }); faceStatus(); $('#place').disabled = false; const lb = $('#place-ring'); if (lb) lb.disabled = false; } const face = state.faces[i]; strike(face); g.classList.remove('is-struck'); requestAnimationFrame(function () { g.classList.add('is-struck'); }); window.setTimeout(function () { g.classList.remove('is-struck'); }, 520); }); function faceStatus() { const el = $('#face-status'); if (!el) return; if (state.selFace < 0 || !state.faces[state.selFace]) { el.textContent = 'strike a stone'; return; } const f = state.faces[state.selFace]; el.textContent = 'face ' + String(f.i + 1).padStart(2, '0') + ' · ' + f.t0.toFixed(2) + '–' + f.t1.toFixed(2) + ' s · seal ' + f.seal; } /* ————— weather ————— */ function climateText() { const w = state.weather; const age = w.age < 0.4 ? 'young' : w.age < 0.7 ? 'middle' : 'old'; const air = w.air < 0.35 ? 'dry' : w.air < 0.7 ? 'damp' : 'wet'; const heat = w.heat < 0.35 ? 'cold' : w.heat < 0.7 ? 'warm' : 'hot'; return age + ', ' + air + ', ' + heat; } function updateClimate() { const el = $('#climate'); if (el) el.textContent = climateText(); } function initWeather() { ['age', 'pressure', 'air', 'heat'].forEach(function (k) { const el = $('#w-' + k); if (!el) return; state.weather[k] = +el.value / 100; const out = el.parentNode.querySelector('output'); out.textContent = state.weather[k].toFixed(2); el.addEventListener('input', function () { state.weather[k] = +el.value / 100; out.textContent = state.weather[k].toFixed(2); updateClimate(); if (E) E.setWeather(state.weather); }); }); updateClimate(); if (E) E.setWeather(state.weather); } /* ————— rings ————— */ function drawRings() { const box = $('#ring-visual'); if (!box) return; const W = box.clientWidth, H = box.clientHeight; if (!W || !H) return; const cx = W / 2, cy = H / 2; let s = ''; const n = Math.min(state.rings, 12); const maxR = Math.min(W * 0.46, H * 0.46); for (let i = 0; i < n; i++) { const r = maxR * (i + 1) / Math.max(n, 4); const off = (i % 2 ? 1 : -1) * (1 + i * 0.3); s += ''; } s += ''; box.innerHTML = s; const count = $('#ring-count'); if (count) count.textContent = String(state.rings).padStart(2, '0'); } /* ————— sound ————— */ function strike(face) { if (!E || !face) return; const core = cores[state.sel]; if (!core) return; E.strike(core.id, face.i, { pitch: face.semis, gain: 0.9, ghost: state.ghost }); } /* ————— view, core select, fracture, place ————— */ function updateHeader() { const core = cores[state.sel]; const name = $('#tray-name'); const dur = $('#tray-dur'); const sealEl = $('#tray-seal'); if (name) name.textContent = core.name; if (dur) dur.textContent = core.dur.toFixed(1) + ' s'; if (sealEl) { sealEl.className = 'seal seal--' + core.seal; const t = sealEl.querySelector('span'); if (t) t.textContent = core.seal; } } function selectCore(i, keepSeed) { state.sel = clamp(i, 0, cores.length - 1); state.selFace = -1; $('#place').disabled = true; const lb = $('#place-ring'); if (lb) lb.disabled = true; const core = cores[state.sel]; state.lens = core.cuts[0] || 0; updateHeader(); highlightStore(); if (!keepSeed) state.faceSeed = core.seed * 13 + state.fracture * 7; redraw(); faceStatus(); } function redraw() { drawTray(); buildOverlay(); buildFaces(state.faceSeed); drawRings(); } function fractureCore() { const core = cores[state.sel]; state.fracture++; if (E) E.refracture(core.id); state.faceSeed = core.seed * 13 + state.fracture * 7; state.selFace = -1; state.lens = core.cuts[0]; $('#place').disabled = true; const lb2 = $('#place-ring'); if (lb2) lb2.disabled = true; redraw(); faceStatus(); if (wall.animate) { wall.animate([{ opacity: 0.25 }, { opacity: 1 }], { duration: 420, easing: 'ease-out' }); } log('faces.fracture', core.id + ' · 16 faces', '', 'hand'); } function placeFace(loop) { if (state.selFace < 0 || !state.faces[state.selFace]) return; const core = cores[state.sel]; if (!core || !window.ZHILA || !window.ZHILA.addStoneClip) return; const f = state.faces[state.selFace]; const label = 'face ' + String(f.i + 1).padStart(2, '0'); const res = window.ZHILA.addStoneClip(label, f.seal, { core: core.id, face: f.i, ring: loop ? 2 : 0, pitch: 0, gain: 0.85 }); const st = $('#face-status'); if (res) { if (st) st.textContent = (loop ? 'looping in ' : 'placed in ') + res.lane + ', bar ' + res.start + '–' + res.end; } } function extract(by, query) { if (!E) return; const name = by === 'mcp' ? (query.length > 26 ? query.slice(0, 26) + '…' : query) : (handBank.length ? handBank.shift() : 'new core'); const core = E.newCore({ name: name, seal: by === 'mcp' ? 'found' : 'taken' }); log('cores.extract', core.name, core.seal, by); } /* ————— view switch ————— */ function setView(v) { document.body.dataset.view = v; $$('.view-btn').forEach(function (b) { const on = b.dataset.viewBtn === v; b.classList.toggle('is-on', on); b.setAttribute('aria-pressed', String(on)); }); if (v === 'quarry') { requestAnimationFrame(function () { selectCore(state.sel, true); }); } else { requestAnimationFrame(function () { if (window.ZHILA && window.ZHILA.buildNave) window.ZHILA.buildNave(); if (window.ZHILA && window.ZHILA.buildLattice) window.ZHILA.buildLattice(); }); } } $$('.view-btn').forEach(function (b) { b.addEventListener('click', function () { setView(b.dataset.viewBtn); }); }); /* ————— tray scrubbing ————— */ let scrubbing = false; function scrub(e) { const r = stage.getBoundingClientRect(); const g = trayGeom(); if (!g.W) return; const x = clamp(e.clientX - r.left, g.x0, g.x1); const core = cores[state.sel]; state.lens = +(((x - g.x0) / (g.x1 - g.x0)) * core.dur).toFixed(2); updateLensOnly(); } stage.addEventListener('pointerdown', function (e) { scrubbing = true; stage.setPointerCapture(e.pointerId); scrub(e); e.preventDefault(); }); stage.addEventListener('pointermove', function (e) { if (scrubbing) scrub(e); }); stage.addEventListener('pointerup', function () { scrubbing = false; }); stage.addEventListener('pointercancel', function () { scrubbing = false; }); /* ————— wiring ————— */ $('#fracture').addEventListener('click', fractureCore); $('#place').addEventListener('click', function () { placeFace(false); }); const loopBtn = $('#place-ring'); if (loopBtn) loopBtn.addEventListener('click', function () { placeFace(true); }); const updatePlace = function () { const on = state.selFace >= 0; $('#place').disabled = !on; if (loopBtn) loopBtn.disabled = !on; }; $('#ghost').addEventListener('click', function () { state.ghost = !state.ghost; this.setAttribute('aria-pressed', String(state.ghost)); this.textContent = state.ghost ? 'on' : 'off'; }); $('#grow').addEventListener('click', function () { state.rings = Math.min(state.rings + 1, 12); if (E) E.setRings(state.rings); drawRings(); log('rings.grow', 'ring ' + String(state.rings).padStart(2, '0') + ' · ' + climateText(), '', 'hand'); }); $('#extract').addEventListener('click', function () { extract('hand'); }); const choirForm = $('#choir-call'); const choirInput = $('#choir-input'); choirForm.addEventListener('submit', function (e) { e.preventDefault(); const q = choirInput.value.trim(); if (!q || document.body.dataset.mode === 'hand') return; choirInput.value = ''; choirForm.classList.add('is-out'); window.setTimeout(function () { extract('mcp', q); choirForm.classList.remove('is-out'); }, 950); }); function syncChoir() { const hand = document.body.dataset.mode === 'hand'; choirInput.disabled = hand; choirInput.placeholder = hand ? 'the choir is resting' : 'send the choir for…'; } new MutationObserver(syncChoir).observe(document.body, { attributes: true, attributeFilter: ['data-mode'] }); choirForm.addEventListener('pointerdown', function () { if (document.body.dataset.mode === 'hand') { const btn = document.querySelector('[data-mode-btn="choir"]'); if (btn) btn.click(); window.setTimeout(function () { choirInput.focus(); }, 40); } }); let rt = null; window.addEventListener('resize', function () { window.clearTimeout(rt); rt = window.setTimeout(function () { if (document.body.dataset.view !== 'quarry') return; drawTray(); buildOverlay(); buildFaces(state.faceSeed); drawRings(); }, 180); }); /* ————— boot ————— */ function setWeatherUI(w) { ['age', 'pressure', 'air', 'heat'].forEach(function (k) { if (w[k] == null) return; state.weather[k] = clamp(+w[k], 0, 1); const el = $('#w-' + k); if (el) { el.value = Math.round(state.weather[k] * 100); const out = el.parentNode.querySelector('output'); if (out) out.textContent = state.weather[k].toFixed(2); } }); if (E) E.setWeather(state.weather); updateClimate(); return state.weather; } function setRingsUI(n) { state.rings = clamp(Math.round(+n || 0), 0, 12); if (E) E.setRings(state.rings); drawRings(); return state.rings; } function refresh() { buildStore(); selectCore(state.sel, true); } function focusCore(id) { const idx = cores.findIndex(function (c) { return c.id === id; }); if (idx >= 0) selectCore(idx); else selectCore(state.sel, true); } function boot() { if (E) { E.onCores(function (core) { buildStore(); const idx = E.cores.indexOf(core); if (idx >= 0) { state.sel = idx; if (document.body.dataset.view === 'quarry') selectCore(idx); else updateHeader(); } }); E.onLog(renderLedger); E.setRings(state.rings); } buildStore(); initWeather(); renderLedger(); selectCore(0, false); syncChoir(); } if (document.readyState === 'complete' || document.readyState === 'interactive') { window.setTimeout(boot, 0); } else { document.addEventListener('DOMContentLoaded', boot); } window.ZHILA_QUARRY = { setView: setView, state: state, cores: cores, focusCore: focusCore, setWeatherUI: setWeatherUI, setRingsUI: setRingsUI, refresh: refresh, weather: function () { return state.weather; }, rings: function () { return state.rings; }, selectedCore: function () { return cores[state.sel] || null; } }; })();