Arcane Curiosities Vol.1 · chest.js
宝箱
48×48 の宝箱で、板の箱、後ろの蝶番で開くかまぼこ形の蓋、真鍮と鉄の金具をいくつかの立体で組み、すべての画素をその模型をのぞき込んで決めています。そのため 4 方向は同じ宝箱を床の上で回したもので、どれも世界の側の左上から光が当たり、蓋はどの向きでも本当の蝶番で開きます。各方向に closed(12 コマ、真鍮を光が渡る)、open(8 コマの一回再生、つぶれて跳ね、蓋が行き過ぎて戻り、金色の光があふれる)、opened(12 コマ、硬貨の山と宝石、きらめきと昇る光の粒)があります。
6×
動くスプライト素材: Arcane Curiosities Vol.1(itch.io)動く小物 12 点・52 アニメ
冒頭のコメント。これが指示書です。サイズ、視点、光の向き、すべての変種が書かれています。
chest.js · 1–11
// Treasure chest — 48x48, top-down 3/4, key light top-left.// ONE chest, modelled as a few convex solids (plank box with a hollow, barrel lid on a back hinge, brass and iron// fittings), and every pixel is found by casting the oblique view ray (screen y = BASE - z - K*depth) into it.// So the four facings are the same chest turned on the floor, each lit top-left in WORLD space (right is not a// mirrored left), and the lid swings on its real hinge in every view. Tones come from the surface normal through// 4-step material ramps; plank seams, lit lips, rivets and brass rims are countable marks added from the surface// coordinates and the pixel's neighbours (no per-pixel noise).// Variants (f = down / up / left / right):// closed-f idle loop, 12f — a glint walks across the brass// open-f one-shot, 8f — squash, hop, lid swings past rest and settles, gold light spills out// opened-f idle loop, 12f — coin heap + gem, sparkles twinkle, motes rise最後の数行。submit でキャンバスの大きさと、描画エンジンが書き出す変種の一覧を指定します。
chest.js · 414–423
submit(Chest, { size: [48, 48], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: 1.2, loop: true }, anchor: { x: 24, y: GROUND }, variants: [ ...FACES.map((f) => ({ name: `closed-${f}`, args: ["closed", f] })), ...FACES.map((f) => ({ name: `open-${f}`, args: ["open", f], animate: { fps: 10, duration: N_OPEN / 10, loop: false } })), ...FACES.map((f) => ({ name: `opened-${f}`, args: ["opened", f] })), ],});プログラム全体を表示 (423 行)
chest.js · 423 行
// Treasure chest — 48x48, top-down 3/4, key light top-left.// ONE chest, modelled as a few convex solids (plank box with a hollow, barrel lid on a back hinge, brass and iron// fittings), and every pixel is found by casting the oblique view ray (screen y = BASE - z - K*depth) into it.// So the four facings are the same chest turned on the floor, each lit top-left in WORLD space (right is not a// mirrored left), and the lid swings on its real hinge in every view. Tones come from the surface normal through// 4-step material ramps; plank seams, lit lips, rivets and brass rims are countable marks added from the surface// coordinates and the pixel's neighbours (no per-pixel noise).// Variants (f = down / up / left / right):// closed-f idle loop, 12f — a glint walks across the brass// open-f one-shot, 8f — squash, hop, lid swings past rest and settles, gold light spills out// opened-f idle loop, 12f — coin heap + gem, sparkles twinkle, motes riseconst CX = 24.5, GROUND = 45, K = 0.4; // K: screen rise per pixel of depthconst L = 31, D = 19, H = 12, WALL = 3, R = 5; // length, depth, body height, lid wall, barrel riseconst HL = L / 2, HD = D / 2, T = 1.6, LIDZ = H + WALL, BX = 8;const OPEN = 105; // resting angle of the open lid, degreesconst N_IDLE = 12, N_OPEN = 8;const LV = norm3([-0.6, -0.25, 0.76]); // toward the key light (world: X right, Y away, Z up)const WOOD = ["w0", "w1", "w2", "w3"], IRON = ["n0", "n1", "n2", "n3"], GOLD = ["g0", "g1", "g2", "g3"];function norm3(v) { const m = Math.hypot(v[0], v[1], v[2]); return [v[0] / m, v[1] / m, v[2] / m]; }// chest-local (x along the length, y front(-)..back(+), z up) <-> world, per facingconst FACINGS = { down: { toL: (v) => [v[0], v[1], v[2]], toW: (v) => [v[0], v[1], v[2]], ext: HD, shadow: 17 }, up: { toL: (v) => [-v[0], -v[1], v[2]], toW: (v) => [-v[0], -v[1], v[2]], ext: HD, shadow: 17 }, right: { toL: (v) => [v[1], -v[0], v[2]], toW: (v) => [-v[1], v[0], v[2]], ext: HL, shadow: 12 }, left: { toL: (v) => [-v[1], v[0], v[2]], toW: (v) => [v[1], -v[0], v[2]], ext: HL, shadow: 12 },};// ---- solids: boxes [x0,x1,y0,y1,z0,z1], optionally cut by a quadric {c, inv2: 1/r^2 per axis (0 drops the axis)}const quad = (c, r) => ({ c, inv2: r.map((v) => (isFinite(v) ? 1 / (v * v) : 0)) });const BODY = [-HL, HL, -HD, HD, 0, H];const CAV = [-HL + T, HL - T, -HD + T, HD - T, T, 99];const LOCK = [-3.5, 3.5, -HD - 0.8, -HD, H - 6, H];const HANDLES = [[HL, HL + 1, -1.5, 1.5, H - 4, H - 2], [-HL - 1, -HL, -1.5, 1.5, H - 4, H - 2]];const KNUCKLES = [[2.5, 5.5, HD, HD + 1, H - 1, H + 1], [-5.5, -2.5, HD, HD + 1, H - 1, H + 1]];const LIDBOX = [-HL, HL, -HD, HD, H, LIDZ];const BARREL = [-HL, HL, -Infinity, Infinity, LIDZ, Infinity], BARREL_Q = quad([0, 0, LIDZ], [Infinity, HD, R]);const HASP = [-1.5, 1.5, -HD - 1.2, -HD, H - 2.5, H + 2];const HEAP_R = [HL - 1, HD, 9], HEAP_C = [0, 0, H - 4];const HEAP = [-HL + T, HL - T, -HD + T, HD - T, 0, 99], HEAP_Q = quad(HEAP_C, HEAP_R);const heapZ = (x, y) => HEAP_C[2] + HEAP_R[2] * Math.sqrt(Math.max(0, 1 - (x / HEAP_R[0]) ** 2 - (y / HEAP_R[1]) ** 2));const GEM = [-99, 99, -99, 99, -99, 99], GEM_Q = quad([-4, -2, heapZ(-4, -2) + 0.9], [2.8, 2.2, 2.2]);// cheap bounds: if the ray misses both, it misses the chestconst BOUND_C = [-HL - 1.1, HL + 1.1, -HD - 1.1, HD + 1.1, 0, H + 1.1], BOUND_GOLD = [-HL, HL, -HD, HD, 0, H + 8];const BOUND_L = [-HL - 0.1, HL + 0.1, -HD - 1.3, HD + 0.1, H - 2.6, LIDZ + R + 0.1];// ray vs convex solid; no allocation: results in TIN/TOUT and the entry/exit face codes (0..5 slab faces, 6 quadric)let TIN = 0, TOUT = 0, CIN = -1, COUT = -1;function convex(o, d, b, q) { let tin = -1e9, tout = 1e9, cin = -1, cout = -1; for (let i = 0; i < 3; i++) { const lo = b[2 * i], hi = b[2 * i + 1], di = d[i], oi = o[i]; if (lo === -Infinity && hi === Infinity) continue; if (di < 1e-9 && di > -1e-9) { if (oi < lo || oi > hi) return false; continue; } let t1 = (lo - oi) / di, t2 = (hi - oi) / di, c1 = 2 * i, c2 = 2 * i + 1; if (t1 > t2) { const k = t1; t1 = t2; t2 = k; c1 = 2 * i + 1; c2 = 2 * i; } if (t1 > tin) { tin = t1; cin = c1; } if (t2 < tout) { tout = t2; cout = c2; } if (tin > tout) return false; } if (q) { const r = q.inv2, c = q.c; const ox = o[0] - c[0], oy = o[1] - c[1], oz = o[2] - c[2]; const A = d[0] * d[0] * r[0] + d[1] * d[1] * r[1] + d[2] * d[2] * r[2]; const B = 2 * (ox * d[0] * r[0] + oy * d[1] * r[1] + oz * d[2] * r[2]); const C = ox * ox * r[0] + oy * oy * r[1] + oz * oz * r[2] - 1; const disc = B * B - 4 * A * C; if (disc < 0) return false; const sq = Math.sqrt(disc), r1 = (-B - sq) / (2 * A), r2 = (-B + sq) / (2 * A); if (r1 > tin) { tin = r1; cin = 6; } if (r2 < tout) { tout = r2; cout = 6; } if (tin > tout) return false; } TIN = tin; TOUT = tout; CIN = cin; COUT = cout; return true;}// outward normal of face `code` at point pfunction normalOf(code, p, q) { if (code < 6) { const n = [0, 0, 0]; n[code >> 1] = code & 1 ? 1 : -1; return n; } return norm3([(p[0] - q.c[0]) * q.inv2[0], (p[1] - q.c[1]) * q.inv2[1], (p[2] - q.c[2]) * q.inv2[2]]);}// ---- one frame of the chest: cast every pixel, then classify and tonefunction castChest(face, st) { const F = FACINGS[face], BASE = GROUND - K * F.ext; const a = (st.th * Math.PI) / 180, ca = Math.cos(a), sa = Math.sin(a); const lidNormal = (n) => [n[0], n[1] * ca + n[2] * sa, -n[1] * sa + n[2] * ca]; const d = F.toL([0, 1 / st.sx, -K / st.sz]); const dl = [d[0], d[1] * ca - d[2] * sa, d[1] * sa + d[2] * ca]; const ex = F.toL([1 / st.sx, 0, 0]), ez = F.toL([0, 0, -1 / st.sz]); const o0 = F.toL([(0.5 - CX) / st.sx, 0, (BASE - 0.5 - st.hop) / st.sz]); const pr = (q) => project(F, BASE, st, q); // screen rectangle of a finite box (chest or lid frame): pixels outside it skip that solid entirely const rect = (b, lid) => { const r = [99, -99, 99, -99]; for (const x of [b[0], b[1]]) for (const y of [b[2], b[3]]) for (const z of [b[4], b[5]]) { const [sx, sy] = pr(lid ? [x, HD + (y - HD) * ca + (z - H) * sa, H - (y - HD) * sa + (z - H) * ca] : [x, y, z]); r[0] = Math.min(r[0], sx); r[1] = Math.max(r[1], sx); r[2] = Math.min(r[2], sy); r[3] = Math.max(r[3], sy); } return [Math.floor(r[0]) - 1, Math.ceil(r[1]) + 1, Math.floor(r[2]) - 1, Math.ceil(r[3]) + 1]; }; const R_BODY = rect(BODY, false), R_LOCK = rect(LOCK, false), R_GOLD = rect([-HL, HL, -HD, HD, 0, H + 8], false); const R_HANDLES = HANDLES.map((h) => rect(h, false)), R_KNUCKLES = KNUCKLES.map((k) => rect(k, false)); const R_LID = rect([-HL, HL, -HD, HD, H, LIDZ + R], true), R_HASP = rect(HASP, true); const o = [0, 0, 0], ol = [0, 0, 0]; const bc = st.gold ? BOUND_GOLD : BOUND_C; const buf = new Array(48 * 48).fill(null); for (let py = 0; py < 48; py++) for (let px = 0; px < 48; px++) { for (let i = 0; i < 3; i++) o[i] = o0[i] + px * ex[i] + py * ez[i]; ol[0] = o[0]; ol[1] = HD + (o[1] - HD) * ca - (o[2] - H) * sa; ol[2] = H + (o[1] - HD) * sa + (o[2] - H) * ca; const inC = convex(o, d, BOUND_C, null) || (st.gold && convex(o, d, bc, null)); const inL = convex(ol, dl, BOUND_L, null); if (!inC && !inL) continue; const inR = (r) => px >= r[0] && px <= r[1] && py >= r[2] && py <= r[3]; let bt = 1e9, bid = null, blid = false, bcode = -1, bq = null, bflip = false; const hit = (id, lid, box, q) => { if (convex(lid ? ol : o, lid ? dl : d, box, q) && TIN < bt) { bt = TIN; bid = id; blid = lid; bcode = CIN; bq = q; bflip = false; } }; if (inC) { if (inR(R_BODY) && convex(o, d, BODY, null)) { const tb = TIN, cb = CIN, qx = o[0] + tb * d[0], qy = o[1] + tb * d[1]; if (cb === 5 && Math.abs(qx) < HL - T && Math.abs(qy) < HD - T) { // fell in through the opening: the far inner wall or the floor if (convex(o, d, CAV, null) && TOUT < bt) { bt = TOUT; bid = "inner"; blid = false; bcode = COUT; bq = null; bflip = true; } } else if (tb < bt) { bt = tb; bid = "body"; blid = false; bcode = cb; bq = null; bflip = false; } } if (inR(R_LOCK)) hit("lock", false, LOCK, null); for (let i = 0; i < 2; i++) { if (inR(R_HANDLES[i])) hit("handle", false, HANDLES[i], null); if (inR(R_KNUCKLES[i])) hit("knuckle", false, KNUCKLES[i], null); } if (st.gold && inR(R_GOLD)) { hit("heap", false, HEAP, HEAP_Q); hit("gem", false, GEM, GEM_Q); } } if (inL) { if (inR(R_LID)) { hit("lidbox", true, LIDBOX, null); hit("barrel", true, BARREL, BARREL_Q); } if (inR(R_HASP)) hit("hasp", true, HASP, null); } if (!bid) continue; const oo = blid ? ol : o, dd = blid ? dl : d; const p = [oo[0] + bt * dd[0], oo[1] + bt * dd[1], oo[2] + bt * dd[2]]; let n = normalOf(bcode, p, bq); if (bflip) n = [-n[0], -n[1], -n[2]]; const nw = F.toW(blid ? lidNormal(n) : n); const c = classify(bid, p, n); c.lid = blid; c.t = bt; c.d = nw[0] * LV[0] + nw[1] * LV[1] + nw[2] * LV[2]; c.p = p; buf[py * 48 + px] = c; } return { buf, proj: (q) => project(F, BASE, st, q) };}function project(F, BASE, st, q) { const w = F.toW(q); return [CX + w[0] * st.sx - 0.5, BASE - st.hop - w[2] * st.sz - K * w[1] * st.sx - 0.5, w[1] * st.sx];}function classify(id, p, n) { const x = p[0], y = p[1], z = p[2], ax = Math.abs(x), ay = Math.abs(y); const band = Math.abs(ax - BX) <= 1.5, hinge = ax >= 2.5 && ax <= 5.5; const rivet = Math.abs(ax - BX) < 0.5 && Math.abs((((z % 4) + 4) % 4) - 1.5) < 0.5; const face = n[2] > 0.5 ? "top" : n[2] < -0.5 ? "under" : n[1] < -0.5 ? "front" : n[1] > 0.5 ? "back" : n[0] > 0 ? "endR" : "endL"; const u = face === "front" ? x : face === "back" ? -x : y; const CAP = { part: "caps", mat: "brass" }, BAND = { part: "bands", mat: "iron", rivet }; if (id === "body") { if (face === "top") return band ? BAND : { part: "body", mat: "wood", s: "rim", k: 0, u }; const end = face === "endL" || face === "endR"; if (z < 3.5 && (end ? ay > HD - 3.5 : ax > HL - 3.5)) return CAP; if (!end && band) return BAND; if (face === "back" && hinge && z > H - 4) return { part: "hinges", mat: "iron", rivet: Math.abs(ax - 4) < 0.5 && Math.abs(z - 9.5) < 0.5 }; if (end) { const ry = y / 2.5, rz = (z - (H - 6)) / 2, rr = ry * ry + rz * rz; if (rr > 0.3 && rr < 1.1) return { part: "handles", mat: "iron", ring: true }; } return { part: "body", mat: "wood", s: face, k: Math.floor(z / 4), u, z }; } if (id === "inner") return { part: "interior", mat: "dark", s: face }; if (id === "lock" || id === "hasp") return { part: "lock", mat: "brass", key: id === "lock" && face === "front" && ax < 0.5 && z > 7 && z < 9.2 }; if (id === "handle") return { part: "handles", mat: "iron", ring: true }; if (id === "knuckle") return { part: "hinges", mat: "iron", knuckle: true, z }; if (id === "heap") return { part: "gold", mat: "gold" }; if (id === "gem") return { part: "gem", mat: "gem", n }; if (id === "lidbox") { if (face === "under") { if (ax > HL - 1.5 || ay > HD - 1.5) return { part: "bands", mat: "iron", rim: true }; return { part: "lid", mat: "under", s: "under", k: Math.floor((y + HD) / 3.6), u: x, bounce: y > HD - 6 }; } const end = face === "endL" || face === "endR"; if (end ? ay > HD - 3.5 : ax > HL - 3.5) return CAP; if (z < H + 1) return { part: "bands", mat: "iron", rim: true }; if (!end && band) return BAND; if (face === "back" && hinge) return { part: "hinges", mat: "iron" }; return { part: "lid", mat: "wood", s: "wall" + face, k: 0, u }; } // barrel: curved top, or the D-shaped end board if (Math.abs(n[0]) > 0.9) { const e = Math.hypot(y / HD, (z - LIDZ) / R); if (e > 0.8) return { part: "bands", mat: "iron", rim: true }; return { part: "lid", mat: "wood", s: face, k: 0, u: y }; } const phi = Math.atan2((z - LIDZ) / R, y / HD); // 0 = back edge, PI = front edge if (band) return BAND; if (hinge && phi < 0.45) return { part: "hinges", mat: "iron" }; return { part: "lid", mat: "wood", s: "barrel", k: Math.floor(phi / (Math.PI / 3)), u: x };}function level(d) { return d > 0.78 ? 3 : d > 0.45 ? 2 : d > 0.1 ? 1 : 0; }function tone(buf, fx) { const keys = new Array(48 * 48).fill(null); for (let i = 0; i < 48 * 48; i++) { const b = buf[i]; if (!b) continue; const px = i % 48, py = (i - px) / 48; const up = py > 0 ? buf[i - 48] : null, dn = py < 47 ? buf[i + 48] : null; const lf = px > 0 ? buf[i - 1] : null, rt = px < 47 ? buf[i + 1] : null; const samePart = (q) => q && q.part === b.part; let key; if (b.mat === "wood" || b.mat === "under") { let lv = b.mat === "under" ? (b.bounce ? 2 : 1) + (fx.glow || 0) : level(b.d); const seam = (q) => q && q.s === b.s && q.k !== b.k; if (seam(rt) || seam(dn)) lv = 0; else if (seam(up) || seam(lf)) lv = Math.min(3, lv + 1); else if (b.z !== undefined && Math.floor(b.z) % 4 === 2 && hash(b.k * 3 + (b.s === "front" ? 0 : 11), Math.floor((b.u + 40) / 4)) > 0.62 && Math.floor(b.u + 40) % 4 !== 0) lv = Math.max(0, lv - 1); if (!b.lid && up && up.lid && b.s !== "top") lv = 0; // the lid's lip shades the body's top plank if (!up && lv > 0) lv = Math.min(3, lv + 1); // the top silhouette catches the light key = WOOD[Math.max(0, Math.min(3, lv))]; if (fx.backlit && b.lid && !up) key = "g2"; // gold glow behind the lid rims its top edge } else if (b.mat === "iron") { let lv = level(b.d); if (!samePart(lf) && !b.rim) lv = Math.min(3, lv + 1); else if (!samePart(rt) && !b.rim) lv = Math.max(0, lv - 1); if (b.rim && !samePart(up)) lv = Math.min(3, lv + 1); if (b.ring) lv = Math.max(lv, 2) + (!samePart(up) || !samePart(lf) ? 1 : 0) - (!samePart(dn) ? 1 : 0); if (b.knuckle) lv = b.z > H ? 3 : 1; lv = Math.max(0, Math.min(3, lv)); key = b.rivet ? "n4" : lv === 3 && (b.d > 0.85 || (b.knuckle && !samePart(lf))) ? "n4" : IRON[lv]; if (fx.backlit && b.lid && !up) key = "g1"; } else if (b.mat === "brass") { const same = (q) => q && q.mat === "brass"; let lv = b.d > 0.5 ? 2 : 1; if (!same(up)) lv = b.d > 0.1 ? 3 : 2; else if (!same(lf)) lv = 2; else if (!same(dn) || !same(rt)) lv = 0; if (fx.glint) lv = Math.min(3, lv + fx.glint(px, py)); key = b.key ? "ink" : GOLD[lv]; } else if (b.mat === "gold") { let lv = b.d > 0.55 ? 2 : b.d > 0.2 ? 1 : 0; const row = Math.floor(py / 2), col = Math.floor((px + (row & 1) * 1.5) / 3); const cy = py % 2, cxp = Math.floor(px + (row & 1) * 1.5) % 3; if (cy === 0 && cxp === 1) lv += 1; else if (cy === 1 && cxp === 2) lv -= 1; if (hash(col, row) > 0.86 && cy === 0) lv = 3; key = GOLD[Math.max(0, Math.min(3, lv + (fx.glow || 0)))]; } else if (b.mat === "gem") { const f = b.n; // faceted: quantise the normal const dd = Math.round(f[0] * 2) * -0.5 + Math.round(f[2] * 2) * 0.6 - Math.round(f[1] * 2) * 0.2; key = dd > 1.0 ? "b3" : dd > 0.3 ? "b2" : dd > -0.4 ? "b1" : "b0"; } else if (b.mat === "dark") { key = b.s === "top" ? "w0" : b.d > 0.2 ? "w1" : "w0"; } keys[i] = key; } return keys;}const PARTS = ["body", "lid", "interior", "gold", "gem", "bands", "hinges", "handles", "caps", "lock"];function drawChest(g, face, st, fx) { const { buf, proj } = castChest(face, st); const keys = tone(buf, fx); let x0 = 47, x1 = 0, y0 = 47, y1 = 0; const seen = new Set(); // only declare the parts this view actually shows (an occluded part is not a silent one) for (let i = 0; i < 48 * 48; i++) if (buf[i]) { const x = i % 48, y = (i - x) / 48; x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y); seen.add(buf[i].part); } g.outlined("ink", () => { for (const part of PARTS) if (seen.has(part)) g.part(part, () => g.field({ x: [x0, x1], y: [y0, y1] }, (x, y) => { const b = buf[y * 48 + x]; return b && b.part === part ? keys[y * 48 + x] : null; })); }); return { buf, proj };}// ---- effectsfunction visible(buf, x, y, depth, slack) { if (x < 0 || y < 0 || x > 47 || y > 47) return false; const b = buf[y * 48 + x]; return !b || depth < b.t + slack;}function star(g, buf, x, y, depth, size, slack) { const put = (px, py, k) => { if (visible(buf, px, py, depth, slack)) g.pixel(px, py, k); }; if (size === 0) { put(x, y, "g2"); return; } put(x, y, size > 1 ? "n5" : "g3"); for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) put(x + dx, y + dy, size > 1 ? "g3" : "g2"); if (size > 1) for (const [dx, dy] of [[2, 0], [-2, 0], [0, 2], [0, -2]]) put(x + dx, y + dy, "g2");}// light pouring out of the opening: rays fanned from just under the rim, blocked by the lid and wallsfunction lightRays(g, face, st, frame, amount) { if (amount <= 0) return; const a = (st.th * Math.PI) / 180, ca = Math.cos(a), sa = Math.sin(a); const inLid = (p) => { const q = [p[0], HD + (p[1] - HD) * ca - (p[2] - H) * sa, H + (p[1] - HD) * sa + (p[2] - H) * ca]; if (q[0] < -HL || q[0] > HL || q[1] < -HD || q[1] > HD || q[2] < H) return false; if (q[2] <= LIDZ) return true; return (q[1] / HD) ** 2 + ((q[2] - LIDZ) / R) ** 2 <= 1; }; g.detached("light", () => { for (let i = 0; i < 8; i++) { // eight shafts rising from a ring just under the rim, leaning outward: they fan in every facing const ang = (i / 8) * TAU + 0.35; const origin = [Math.cos(ang) * 9, Math.sin(ang) * 4 - 0.5, H - 1]; const dir = norm3([Math.cos(ang) * 0.3, Math.sin(ang) * 0.3 - 0.15, 1]); const len = amount * (15 + 8 * hash(i, 3)) * (i % 2 ? 0.65 : 1); for (let r = 0; r < len; r += 0.35) { const p = [origin[0] + dir[0] * r, origin[1] + dir[1] * r, origin[2] + dir[2] * r]; if (inLid(p)) break; if (p[2] < H + 0.5) continue; const [sx, sy, dep] = frame.proj(p); const x = Math.round(sx), y = Math.round(sy); if (!visible(frame.buf, x, y, dep, 0.5)) continue; const k = r / len; if (k > 0.7 && !chk(x, y)) continue; g.pixel(x, y, k < 0.3 ? "g3" : k < 0.7 ? "g2" : "g1"); } } });}// [x, y, height above the heap, phase]: some sit on the coins, some hang in the air above themconst SPARKS = [[-8, -2, 0.4, 0.0], [7, 2, 0.4, 0.25], [1, -4, 0.4, 0.5], [-3, 3, 0.4, 0.75], [11, -3, 5, 0.62], [-12, 1, 7, 0.12], [4, -1, 9, 0.37], [-5, 0, 12, 0.87]];function sparkles(g, frame, t) { // the treasure's glow: three faint shafts breathing above the coins (dithered, never outlined) g.detached("glow", () => { for (let k = 0; k < 3; k++) { const x0 = [-7, 1, 8][k], y0 = [-3, 3, -1][k]; const len = 7 + 3 * Math.sin(TAU * (t + k / 3)); for (let r = 0; r < len; r += 0.5) { const [sx, sy, dep] = frame.proj([x0 + r * 0.12 * (k - 1), y0, heapZ(x0, y0) + 1 + r]); const x = Math.round(sx), y = Math.round(sy); if (!chk(x, y) || !visible(frame.buf, x, y, dep, 0.5)) continue; g.pixel(x, y, r < 2 ? "g2" : "g1"); } } }); g.detached("sparkles", () => { for (const [x, y, h, ph] of SPARKS) { const p = (t + ph) % 1; if (p > 0.5) continue; const [sx, sy, dep] = frame.proj([x, y, heapZ(x, y) + h]); const size = p < 0.08 || p > 0.42 ? 0 : p < 0.17 || p > 0.33 ? 1 : 2; star(g, frame.buf, Math.round(sx), Math.round(sy), dep, size, 1.5); } }); g.detached("motes", () => { for (let k = 0; k < 3; k++) { const p = (t + k / 3) % 1; const x = [-6, 3, 9][k] + Math.round(Math.sin(TAU * (p + k * 0.3)) * 1.5), y = [-1, 2, -2][k]; const [sx, sy, dep] = frame.proj([x, y, H + 2 + p * 19]); const px = Math.round(sx), py = Math.round(sy); if (p < 0.8 && visible(frame.buf, px, py, dep, 0.5)) g.pixel(px, py, p < 0.45 ? "g3" : "g2"); } });}// ---- statesconst REST = { th: 0, sx: 1, sz: 1, hop: 0, gold: false };const OPEN_KEYS = [ // th, sx, sz, hop, light [0, 1, 1, 0, 0], [0, 1.04, 0.9, 0, 0], // anticipation: the chest squats [9, 0.97, 1.06, 1, 0.35], // springs up a pixel, lid cracks, first light [48, 1, 1, 0, 1], [90, 1, 1, 0, 1], [118, 1, 1, 0, 0.8], // swings past rest [99, 1, 1, 0, 0.45], [OPEN, 1, 1, 0, 0], // == opened, frame 0];const Chest = sprite(pal, (g, t, state, face) => { contactShadow(g, 24, GROUND, FACINGS[face].shadow, 2); if (state === "closed") { // a glint band sweeps the brass left to right over 3/4 of the loop, then rests off-chest const gp = -6 + (t / 0.75) * 62; const glint = (x, y) => { const q = x + (y - 30) * 0.6 - gp; return Math.abs(q) < 0.8 ? 3 : Math.abs(q) < 2 ? 1 : 0; }; const fr = drawChest(g, face, REST, { glint: t < 0.75 ? glint : null }); // where the band crosses a lit brass rim, a small star flashes g.detached("glint", () => { for (let i = 0; i < 48 * 48; i++) { const b = fr.buf[i]; if (!b || b.mat !== "brass") continue; const x = i % 48, y = (i - x) / 48, up = fr.buf[i - 48]; if (t < 0.75 && (!up || up.mat !== "brass") && Math.abs(x + (y - 30) * 0.6 - gp) < 0.5 && b.part === "lock") star(g, fr.buf, x, y - 1, -99, 1, 0); } }); return; } if (state === "open") { const f = Math.min(N_OPEN - 1, Math.round(t * N_OPEN)); const [th, sx, sz, hop, light] = OPEN_KEYS[f]; const st = { th, sx, sz, hop, gold: th > 0 }; const fr = drawChest(g, face, st, { glow: light > 0.6 ? 1 : 0, backlit: face === "up" && th > 60 }); lightRays(g, face, st, fr, light); if (f === N_OPEN - 1) sparkles(g, fr, 0); return; } const st = { th: OPEN, sx: 1, sz: 1, hop: 0, gold: true }; const fr = drawChest(g, face, st, { backlit: face === "up" }); sparkles(g, fr, t);});const FACES = ["down", "up", "left", "right"];submit(Chest, { size: [48, 48], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: 1.2, loop: true }, anchor: { x: 24, y: GROUND }, variants: [ ...FACES.map((f) => ({ name: `closed-${f}`, args: ["closed", f] })), ...FACES.map((f) => ({ name: `open-${f}`, args: ["open", f], animate: { fps: 10, duration: N_OPEN / 10, loop: false } })), ...FACES.map((f) => ({ name: `opened-${f}`, args: ["opened", f] })), ],});掲載されたコードでは、ビルドスクリプトが自動付与する共通部分を省略しています。具体的には 1 行のランタイムヘッダー、共有パレット、および汎用ヘルパー関数(contactShadow、litIndex、flame、hash、chk、TAU)です。表示されている行番号は実際のファイルと一致しています。これらのプログラムは AIエージェントが生成し、描画されたコマの仕上がりを確認しながら修正を加えたものです。
エージェントに、こうしたプログラムを書かせる。
Tessarune は描画 API のリファレンス、25 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。