Files
zhila/reference/demo/quarry.js
T

957 lines
34 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.
/* 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 '<span class="seal seal--' + kind + '"><i aria-hidden="true"></i><span>' + kind + '</span></span>';
}
function renderLedger() {
entries = E ? E.log : entries;
ledgerEl.innerHTML = entries.slice(0, 3).map(function (e) {
return '<li class="ledger-item">' +
'<div class="ledger-top"><span class="ledger-t">' + e.t + '</span>' +
'<span class="ledger-tool">' + esc(e.tool) + '</span>' +
'<span class="ledger-by' + (e.by === 'mcp' ? ' is-mcp' : '') + '">' + e.by + '</span></div>' +
'<div class="ledger-detail">' + esc(e.detail) + ' ' + sealHTML(e.seal) + '</div>' +
'</li>';
}).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 =
'<canvas class="core-thumb" width="96" height="116" aria-hidden="true"></canvas>' +
'<span class="core-body">' +
'<span class="core-name">' + esc(core.name) + '</span>' +
'<span class="core-meta">' + core.dur.toFixed(1) + ' s ' + sealHTML(core.seal) + '</span>' +
'</span>';
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 += '<line x1="' + x0 + '" x2="' + x1 + '" y1="' + (top - 20) + '" y2="' + (top - 20) + '" stroke="rgba(232,64,42,.14)" stroke-width=".7"/>';
core.cuts.forEach(function (cut, i) {
const x = x0 + clamp(cut / core.dur, 0, 1) * (x1 - x0);
s += '<line class="tray-cut" x1="' + x.toFixed(1) + '" x2="' + x.toFixed(1) + '" y1="' + (top - 14) + '" y2="' + (bottom + 12) + '"/>';
s += '<text class="tray-cut-text" x="' + (x + 4).toFixed(1) + '" y="' + (top - 6) + '">0' + (i + 1) + '</text>';
});
const ry = bottom + 30;
s += '<line x1="' + x0 + '" x2="' + x1 + '" y1="' + ry + '" y2="' + ry + '" stroke="rgba(155,133,121,.22)" stroke-width=".7"/>';
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 += '<line class="tray-tick' + (major ? ' tray-tick--major' : '') + '" x1="' + x.toFixed(1) + '" x2="' + x.toFixed(1) + '" y1="' + ry + '" y2="' + (ry + (major ? 6 : 3)) + '"/>';
if (major) {
s += '<text class="tray-tick-text" x="' + (x + 3).toFixed(1) + '" y="' + (ry + 15) + '">' + t.toFixed(0) + 's</text>';
}
}
s += '<g id="tray-lens-g">' +
'<line class="tray-lens"/>' +
'<rect class="tray-lens-handle" width="4" height="12" rx="0"/>' +
'<text class="tray-lens-text"></text>' +
'</g>';
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 = '<path d="M' + (x - 1.4) + ' ' + (y - 1.4) + 'L' + (x + 1.4) + ' ' + (y + 1.4) + 'M' + (x + 1.4) + ' ' + (y - 1.4) + 'L' + (x - 1.4) + ' ' + (y + 1.4) + '" stroke="rgba(232,64,42,.85)" stroke-width=".8" fill="none"/>';
else if (kind === 'found') inner = '<circle cx="' + x + '" cy="' + y + '" r="1.1" class="stone-seal-dot"/>';
else if (kind === 'loan') inner = '<path d="M' + (x - 1.3) + ' ' + (y + 1.3) + 'L' + (x + 1.3) + ' ' + (y - 1.3) + '" stroke="rgba(232,64,42,.85)" stroke-width=".8"/>';
else if (kind === 'gift') inner = '<circle cx="' + x + '" cy="' + y + '" r=".9" fill="none" stroke="rgba(239,228,212,.7)" stroke-width=".7"/>';
return '<circle class="stone-seal" cx="' + x + '" cy="' + y + '" r="3"/>' + 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 = '<svg viewBox="0 0 ' + W + ' ' + H + '" preserveAspectRatio="none"><defs>';
['A', 'B', 'C', 'D'].forEach(function (t, ti) {
svg += '<linearGradient id="stoneGrad' + t + '" x1="0" y1="0" x2="0" y2="1">' +
'<stop offset="0" stop-color="#' + ['2b1811', '22120d', '31201a', '1a0e0a'][ti] + '"/>' +
'<stop offset=".3" stop-color="#' + ['160c08', '120906', '180d09', '0e0705'][ti] + '"/>' +
'<stop offset="1" stop-color="#040202"/>' +
'</linearGradient>';
});
svg += '</defs>';
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 += '<g class="stone' + (isSel ? ' is-sel' : '') + '" data-i="' + idx + '">' +
'<circle class="stone-flash" cx="' + (cen[0]).toFixed(1) + '" cy="' + (cen[1]).toFixed(1) + '" r="' + (Math.min(cell.w, cell.h) * 0.42).toFixed(1) + '"/>' +
'<polygon class="stone-body stone-body--' + tone + '" points="' + ptsStr(pts) + '"/>' +
'<polyline class="stone-lip" points="' + ptsStr(lip) + '"/>' +
'<polyline class="stone-foot-lip" points="' + ptsStr(foot) + '"/>' +
'<polygon class="stone-face" points="' + ptsStr(inner) + '"/>' +
'<polygon class="stone-face stone-face--inner" points="' + ptsStr(inner2) + '"/>' +
'<polyline class="stone-strata" points="' + ptsStr(strata) + '"/>' +
'<polyline class="stone-grain" points="' + ptsStr(grain) + '"/>' +
'<polyline class="stone-grain stone-grain--faint" points="' + ptsStr(grain2) + '"/>' +
'<polyline class="stone-crack" points="' + ptsStr(crack) + '"/>' +
pits.map(function (p) {
return '<ellipse class="stone-pit" cx="' + p[0].toFixed(1) + '" cy="' + p[1].toFixed(1) + '" rx="' + (p[2] * 1.7).toFixed(1) + '" ry="' + p[2].toFixed(1) + '"/>';
}).join('') +
'<text class="stone-num" x="' + (cell.x + 11).toFixed(1) + '" y="' + (cell.y + 17).toFixed(1) + '">' + num + '</text>' +
sealMark(cell.x + cell.w - 14, cell.y + cell.h - 13, core.seal) +
'</g>';
idx++;
x += ww + gap;
});
});
svg += '</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 = '<svg viewBox="0 0 ' + W + ' ' + H + '">';
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 += '<ellipse class="ring-arc" cx="' + (cx + off).toFixed(1) + '" cy="' + cy + '" rx="' + r.toFixed(1) + '" ry="' + (r * 0.58).toFixed(1) + '" stroke-opacity="' + (0.34 + i * 0.05).toFixed(2) + '"/>';
}
s += '<circle cx="' + cx + '" cy="' + cy + '" r="1.2" fill="rgba(232,64,42,.9)"/></svg>';
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; }
};
})();