P0: Rust engine, wire daemon and audio-reactive descent surface

This commit is contained in:
Emil
2026-11-01 00:36:04 +03:00
parent cd967cced4
commit db4368efae
68 changed files with 19507 additions and 3 deletions
+956
View File
@@ -0,0 +1,956 @@
/* 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; }
};
})();