Dungeon Mechanisms · crate.js
木箱と樽
32×32 のプログラムで、壊せる形を 2 つ描きます。板を組み、正面に枠と斜めの筋交い、四隅に鋲留めの鉄板を付けた木箱と、14 枚の板に 2 本の鉄の輪を巻き、栓のある板の蓋をはめた胴のふくらんだ樽です。どの画素もどの板に属するかを知っているので、壊れるときは板が丸ごと動きます。どちらにも idle(8 コマ、鉄に光が走る)、hit(6 コマの一回再生、つぶれて揺れる)、break(10 コマの一回再生、板がはじけて山になり、木くずが舞う)、broken(8 コマ、上の板や立てかかった板がぐらつき、ほこりが落ち着く)があります。
6×
動くスプライト素材: Dungeon Mechanisms(itch.io)Vol.2・罠と扉と壊れる物 12 点
冒頭のコメント。これが指示書です。サイズ、視点、光の向き、すべての変種が書かれています。
crate.js · 1–13
// Supply crate and banded barrel — 32x32, top-down 3/4, key light top-left. Two breakable shapes in one file.// Both are ray-marched per pixel from a small solid model (camera elevation: ellipse ratio 0.35, as in Vol.1), so// the resting prop, every squash/rock pose of the hit and the broken remains come from one model:// crate — a box of boards: top boards, a framed front (rails, posts, two infill planks, a diagonal brace),// iron corner plates with rivets, nails at the board ends.// barrel — a bulged solid of revolution: 14 staves, two iron hoops, a recessed lid of three boards with a bung.// Every pixel knows which board / stave group it belongs to, so the break moves WHOLE boards: they burst, tumble// and land as a pile. Off the prop, a piece is re-lit as a chunk (lit top-left edge, dark lower edge).// Variants (shape s = crate | barrel; the crate's names carry no suffix):// idle[-s] loop 8f — a glint runs over the iron// hit[-s] one-shot 6f — squash on impact, rocks on its bottom edge right/left, settles on idle frame 0// break[-s] one-shot 10f — cracks, boards/staves burst and fall into a pile, splinter puff; ends on broken f0// broken[-s] loop 8f — the pile; the top board (crate) / a leaning stave (barrel) teeters, dust settles最後の数行。submit でキャンバスの大きさと、描画エンジンが書き出す変種の一覧を指定します。
crate.js · 541–545
submit(Breakable, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, anchor: { x: CX, y: GROUND }, variants: V,});プログラム全体を表示 (545 行)
crate.js · 545 行
// Supply crate and banded barrel — 32x32, top-down 3/4, key light top-left. Two breakable shapes in one file.// Both are ray-marched per pixel from a small solid model (camera elevation: ellipse ratio 0.35, as in Vol.1), so// the resting prop, every squash/rock pose of the hit and the broken remains come from one model:// crate — a box of boards: top boards, a framed front (rails, posts, two infill planks, a diagonal brace),// iron corner plates with rivets, nails at the board ends.// barrel — a bulged solid of revolution: 14 staves, two iron hoops, a recessed lid of three boards with a bung.// Every pixel knows which board / stave group it belongs to, so the break moves WHOLE boards: they burst, tumble// and land as a pile. Off the prop, a piece is re-lit as a chunk (lit top-left edge, dark lower edge).// Variants (shape s = crate | barrel; the crate's names carry no suffix):// idle[-s] loop 8f — a glint runs over the iron// hit[-s] one-shot 6f — squash on impact, rocks on its bottom edge right/left, settles on idle frame 0// break[-s] one-shot 10f — cracks, boards/staves burst and fall into a pile, splinter puff; ends on broken f0// broken[-s] loop 8f — the pile; the top board (crate) / a leaning stave (barrel) teeters, dust settlesconst W = 32, H = 32, CX = 16, GROUND = 29;const SE = 0.35, CE = Math.sqrt(1 - SE * SE);const LIGHT = (() => { const v = [-0.55, 0.5, 0.68], m = Math.hypot(...v); return v.map((c) => c / m); })(); // view: right, up, toward cameraconst N_IDLE = 8, N_HIT = 6, N_BREAK = 10, N_BROKEN = 8;const WOOD = ["w0", "w1", "w2", "w3", "w4"], IRON = ["n0", "n1", "n2", "n3", "n4"];// ---- shared camera / pose helpers ------------------------------------------------------------------------------function toObj(x, h, z, T) { const x1 = x - T.dx - T.piv, h1 = h - T.hop; const c = Math.cos(T.ang), s = Math.sin(T.ang); const xo = x1 * c - h1 * s + T.piv, ho = x1 * s + h1 * c; return [xo / T.sx, ho / T.sy, z / T.sx];}function nToView(n, T) { let a = n[0] / T.sx, b = n[1] / T.sy, c3 = n[2] / T.sx; const m = Math.hypot(a, b, c3); a /= m; b /= m; c3 /= m; const c = Math.cos(T.ang), s = Math.sin(T.ang); const wx = a * c + b * s, wh = -a * s + b * c; return [wx, wh * CE - c3 * SE, wh * SE + c3 * CE];}const dotL = (n) => n[0] * LIGHT[0] + n[1] * LIGHT[1] + n[2] * LIGHT[2];function cast(shape, T, cut) { const buf = new Array(W * H).fill(null); for (let py = 0; py < H; py++) for (let px = 0; px < W; px++) { const X = px + 0.5 - CX, Y = py + 0.5 - shape.refy; if (Math.abs(X - T.dx) > 13) continue; for (let d = 18; d >= -18; d -= 0.15) { const wh = -Y * CE + d * SE, wz = Y * SE + d * CE; const [xo, ho, zo] = toObj(X, wh, wz, T); const hit = shape.solid(xo, ho, zo, cut); if (hit) { hit.d = dotL(nToView(hit.n, T)); buf[py * W + px] = hit; break; } } } return shape.tone(buf, cut);}// ---- CRATE -----------------------------------------------------------------------------------------------------const CW = 11, CH = 15, CD = 9, FB = 2.6, PL = 3.3; // half width, height, half depth, frame board, plateconst CRATE = { refy: 26.2, solid(x, h, z) { if (h < 0 || h > CH || Math.abs(x) > CW || Math.abs(z) > CD) return null; // entry face: the nearest of top / front / sides let f = "front", best = CD - z; if (CH - h < best) { f = "top"; best = CH - h; } if (x + CW < best) { f = "left"; best = x + CW; } if (CW - x < best) { f = "right"; best = CW - x; } const n = f === "front" ? [0, 0, 1] : f === "top" ? [0, 1, 0] : f === "left" ? [-1, 0, 0] : [1, 0, 0]; return { f, x, h, z, n }; }, // board id + material of a surface point board(b) { const ax = Math.abs(b.x); if (b.f === "top") { if (ax > CW - PL && Math.abs(b.z) > CD - PL) return { id: b.x < 0 ? "topL" : "topR", m: "iron", rivet: Math.abs(ax - (CW - 1.6)) < 0.55 && Math.abs(Math.abs(b.z) - (CD - 1.6)) < 0.8 }; const k = Math.min(3, Math.floor((b.x + CW) / (CW / 2))); return { id: k < 2 ? "topL" : "topR", m: "wood", k: "t" + k, u: b.z }; } const u = b.f === "front" ? b.x : b.f === "left" ? -b.z : b.z, v = b.h; const side = b.f !== "front"; if (!side && ax > CW - PL && (v > CH - PL || v < PL)) { return { id: b.x < 0 ? "postL" : "postR", m: "iron", rivet: Math.abs(ax - (CW - 1.5)) < 0.55 && Math.abs((v > CH / 2 ? CH - v : v) - 1.5) < 0.55 }; } if (side) return { id: b.f === "left" ? "postL" : "postR", m: "wood", k: "s", u: v }; if (v > CH - FB) return { id: "railT", m: "wood", k: "rT", u: b.x, raised: true }; if (v < FB) return { id: "railB", m: "wood", k: "rB", u: b.x, raised: true }; if (ax > CW - FB) { const nail = Math.abs(ax - (CW - 1.3)) < 0.55 && (Math.abs(v - 5) < 0.55 || Math.abs(v - 10) < 0.55); return { id: b.x < 0 ? "postL" : "postR", m: nail ? "nail" : "wood", k: "p" + (b.x < 0 ? "L" : "R"), u: v, raised: true, post: true }; } // diagonal brace from lower left to upper right over the infill const x0 = -CW + FB, y0 = FB, x1 = CW - FB, y1 = CH - FB; const dist = ((b.x - x0) * (y1 - y0) - (v - y0) * (x1 - x0)) / Math.hypot(x1 - x0, y1 - y0); if (Math.abs(dist) < 1.45) return { id: "brace", m: "wood", k: "br", u: b.x, raised: true, below: dist > 0.6 }; const lower = v < CH / 2; return { id: lower ? "plankB" : "plankT", m: "wood", k: lower ? "pB" : "pT", u: b.x, under: dist > 1.45 && dist < 2.6 }; }, tone(buf) { const out = new Array(W * H).fill(null); const info = buf.map((b) => (b ? this.board(b) : null)); for (let i = 0; i < W * H; i++) { const b = buf[i]; if (!b) continue; const q = info[i], px = i % W, py = (i - px) / W; const up = py > 0 ? info[i - W] : null, dn = py < H - 1 ? info[i + W] : null; const lf = px > 0 ? info[i - 1] : null, rt = px < W - 1 ? info[i + 1] : null; let key, lv; if (q.m === "iron" || q.m === "nail") { lv = b.d > 0.75 ? 3 : 2; const same = (o) => o && o.m === "iron" && o.id === q.id; if (q.m === "iron") { if (!same(up) || !same(lf)) lv = 3; else if (!same(dn) || !same(rt)) lv = 1; } if (q.m === "nail") lv = 1; key = q.rivet ? "n4" : IRON[lv]; out[i] = { key, part: "iron", piece: q.id, lv, m: "iron" }; continue; } // wood: base level by face, then seams and lit edges between boards lv = b.f === "top" ? (b.d > 0.55 ? 3 : 2) : b.f === "front" ? (b.d > 0.8 ? 3 : b.d > 0.45 ? 2 : 1) : 1; if (b.f === "front" && !q.raised) lv = Math.max(1, lv - 1); // infill sits back in the frame if (q.under) lv = Math.max(0, lv - 1); // the brace shades the plank below it const other = (o) => o && o.m !== "iron" && o.k !== q.k; if (other(rt) || other(dn)) lv = b.f === "top" ? 1 : 0; else if (!up || other(up) || other(lf)) lv = Math.min(4, lv + 1); // countable grain marks: a short dark streak on some boards else if (q.k && hash(q.k.charCodeAt(0) * 7 + q.k.length, Math.floor((q.u + 20) / 5)) > 0.72 && Math.floor(q.u + 20) % 5 === 2 && py % 3 === 1) lv = Math.max(0, lv - 1); out[i] = { key: WOOD[lv], part: q.id.startsWith("top") ? "lid" : q.id === "brace" ? "brace" : q.post || q.k === "s" ? "posts" : "boards", piece: q.id, lv, m: "wood" }; } return out; },};// ---- BARREL ----------------------------------------------------------------------------------------------------const BTOP = 17, BLID = 16.1, BWALL = 1.0;const BPROF = [[0, 6.3], [2, 7.1], [5, 7.8], [8.5, 8.1], [12, 7.8], [15, 7.1], [17, 6.3]];const BRT = [];for (let i = 0; i <= 340; i++) { const h = i * 0.05; let r = BPROF[BPROF.length - 1][1]; for (let k = 1; k < BPROF.length; k++) { const [h0, r0] = BPROF[k - 1], [h1, r1] = BPROF[k]; if (h <= h1) { const u = (h - h0) / (h1 - h0), s = u * u * (3 - 2 * u); r = r0 + (r1 - r0) * s; break; } } BRT.push(r);}const BR = (h) => BRT[Math.max(0, Math.min(340, Math.round(h * 20)))];const BdR = (h) => (BR(h + 0.4) - BR(h - 0.4)) / 0.8;const STAVES = 14;const HOOPS = [[2.2, 3.8], [13.2, 14.8]];const BCUT = (th) => { // jagged stave stubs left standing const s = ((th / TAU) * STAVES + 20) % STAVES, k = Math.floor(s); return 4.3 + hash(k, 3.3) * 2.6;};const STAVE_GROUP = (th) => { // which flying piece a stave belongs to: equal screen widths const a = Math.sin(th); // -1..1 across the barrel return a < -0.6 ? 0 : a < -0.2 ? 1 : a < 0.2 ? 2 : a < 0.6 ? 3 : 4;};const BARREL = { refy: 27.3, solid(x, h, z, cut) { if (h < 0 || h > BTOP) return null; const rho = Math.hypot(x, z), r = BR(h); if (rho > r) return null; const rin = r - BWALL, th = Math.atan2(x, z); const hollow = cut ? h > 1.2 : h > BLID; if (hollow && rho < rin) return null; if (cut && h > BCUT(th)) return null; let s = "out", best = r - rho; if (!cut && BTOP - h < best) { s = "chime"; best = BTOP - h; } if (hollow && rho - rin < best) { s = "in"; best = rho - rin; } if (!cut && !hollow && rho < rin && BLID - h < best) { s = "lid"; best = BLID - h; } if (cut && !hollow && rho < rin && 1.2 - h < best) { s = "floor"; best = 1.2 - h; } if (cut && BCUT(th) - h < best) { s = "cut"; best = BCUT(th) - h; } const n = s === "out" ? [x / (rho || 1), -BdR(h), z / (rho || 1)] : s === "in" ? [-x / (rho || 1), 0, -z / (rho || 1)] : [0, 1, 0]; return { s, x, h, z, rho, th, n }; }, tone(buf, cut) { const out = new Array(W * H).fill(null); const stave = (b) => (b && (b.s === "out" || b.s === "cut") ? Math.floor(((b.th / TAU) * STAVES + 20) % STAVES) : -1); for (let i = 0; i < W * H; i++) { const b = buf[i]; if (!b) continue; const px = i % W, py = (i - px) / W; const up = py > 0 ? buf[i - W] : null, rt = px < W - 1 ? buf[i + 1] : null; const hoop = b.s === "out" ? HOOPS.findIndex(([a, c]) => b.h >= a && b.h <= c) : -1; const piece = cut ? "base" : b.s === "lid" || (b.s === "chime" && b.z < 0) ? "lid" : b.h < 4.2 && b.s === "out" ? "stub" : b.s === "out" || b.s === "chime" ? "st" + STAVE_GROUP(b.th) : "inside"; if (b.s === "out" && hoop >= 0) { let lv = b.d > 0.8 ? 3 : b.d > 0.4 ? 2 : b.d > 0 ? 1 : 0; const [a, c] = HOOPS[hoop]; if (b.h > c - 0.55 && lv > 0) lv = Math.min(4, lv + 1); // the hoop's upper edge catches light else if (b.h < a + 0.5) lv = Math.max(0, lv - 1); const rivet = Math.abs(b.h - (a + c) / 2) < 0.5 && Math.abs(((b.th / TAU) * 7 + 20.5) % 1 - 0.5) < 0.07; out[i] = { key: rivet ? "n4" : IRON[lv], part: "hoops", piece, lv, m: "iron", hoopIdx: hoop }; continue; } if (b.s === "out" || b.s === "cut") { let lv = b.d > 0.9 ? 4 : b.d > 0.55 ? 3 : b.d > 0.2 ? 2 : b.d > -0.15 ? 1 : 0; if (lv === 4 && !(b.h > 5 && b.h < 12)) lv = 3; const k = stave(b); if (b.s === "cut" || (cut && (!up || up.s === "in" || up.s === "floor"))) { out[i] = { key: b.x < 2 ? "w3" : "w2", part: "staves", piece, lv: 3, m: "wood" }; continue; } if (stave(rt) >= 0 && stave(rt) !== k && b.x < 5.5) lv = Math.max(0, Math.min(lv, 2) - 1); // stave seam if (b.h < 1 && lv > 0) lv -= 1; out[i] = { key: WOOD[lv], part: "staves", piece, lv, m: "wood" }; continue; } if (b.s === "chime") { out[i] = { key: b.x < 1 ? "w4" : "w3", part: "staves", piece, lv: 4, m: "wood" }; continue; } if (b.s === "lid") { // three boards across x, a dark ring in the chime's shadow, a bung on the right board const r = Math.hypot(b.x, b.z), edge = r > BR(BLID) - BWALL - 0.9; const k = b.x < -2.3 ? 0 : b.x < 2.3 ? 1 : 2; let lv = edge && b.z < 1 ? 1 : b.x < -2.3 ? 3 : 2; const kr = rt && rt.s === "lid" ? (rt.x < -2.3 ? 0 : rt.x < 2.3 ? 1 : 2) : k; if (kr !== k) lv = 1; if (k === 0 && !edge && up && up.s === "chime") lv = 3; const bung = Math.hypot(b.x - 3.7, b.z + 0.6) < 0.95; out[i] = { key: bung ? "w0" : WOOD[lv], part: "lid", piece, lv, m: "wood" }; continue; } out[i] = { key: b.s === "in" && b.x > 0.5 && b.z < 0 ? "w1" : "w0", part: "inside", piece: cut ? "base" : piece, lv: 0, m: "dark" }; } return out; },};// ---- compositing ---------------------------------------------------------------------------------------------function markOcc(occ, idx) { occ[idx] = 1; const x = idx % W, y = (idx - x) / W; for (const [nx, ny] of [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]) if (nx >= 0 && ny >= 0 && nx < W && ny < H) occ[ny * W + nx] = 1;}function emitLayer(g, occ, name, pix, detached) { const seam = []; for (const idx of pix.keys()) { const x = idx % W, y = (idx - x) / W; for (const [nx, ny] of [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]) { if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue; const k = ny * W + nx; if (!pix.has(k) && occ[k]) seam.push(k); } } for (const k of seam) pix.set(k, "ink"); let x0 = W, x1 = -1, y0 = H, y1 = -1; for (const idx of pix.keys()) { const x = idx % W, y = (idx - x) / W; x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y); } if (x1 < 0) return; const body = () => g.outlined("ink", () => g.field({ x: [x0, x1], y: [y0, y1] }, (x, y) => pix.get(y * W + x) ?? null)); if (detached) g.detached(name, body); else g.part(name, body); for (const idx of pix.keys()) markOcc(occ, idx);}function drawBody(g, occ, buf, over, only) { let x0 = W, x1 = -1, y0 = H, y1 = -1; const use = (b) => b && (!only || only(b)); for (let i = 0; i < W * H; i++) if (use(buf[i])) { const x = i % W, y = (i - x) / W; x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y); } if (x1 < 0) return; const parts = []; for (const b of buf) if (use(b) && !parts.includes(b.part)) parts.push(b.part); g.outlined("ink", () => { for (const p of parts) g.part(p, () => g.field({ x: [x0, x1], y: [y0, y1] }, (x, y) => { const i = y * W + x, b = buf[i]; return use(b) && b.part === p ? (over && over.has(i) ? over.get(i) : b.key) : null; })); }); for (let i = 0; i < W * H; i++) if (use(buf[i])) markOcc(occ, i);}// ---- pieces: the intact prop's pixels grouped by board / stave group ------------------------------------------function piecesOf(buf) { const groups = new Map(); for (let i = 0; i < W * H; i++) { const b = buf[i]; if (!b || b.piece === "inside" || b.piece === "base" || b.piece === "stub") continue; if (!groups.has(b.piece)) groups.set(b.piece, []); groups.get(b.piece).push([i % W, Math.floor(i / W), b]); } // close gaps inside each row of a board (the brace covered a strip of both infill planks) for (const [id, px] of groups) { const rows = new Map(); for (const e of px) { if (!rows.has(e[1])) rows.set(e[1], []); rows.get(e[1]).push(e); } for (const [y, r] of rows) { r.sort((a, b) => a[0] - b[0]); for (let j = 1; j < r.length; j++) for (let x = r[j - 1][0] + 1; x < r[j][0]; x++) px.push([x, y, r[j - 1][2]]); } } const out = {}; for (const [id, px] of groups) { let cx = 0, cy = 0; for (const [x, y] of px) { cx += x + 0.5; cy += y + 0.5; } cx /= px.length; cy /= px.length; out[id] = { px, cx, cy, m: new Map(px.map(([x, y, b]) => [`${x},${y}`, b])) }; } return out;}function chunkKey(b, src, x, y) { // a loose board on the floor: flat mid tone, lit top/left edge, dark lower/right edge (rivets keep their spark) const has = (dx, dy) => src.has((y + dy) * W + (x + dx)); if (b.key === "n4") return b.key; const iron = b.m === "iron", ramp = iron ? IRON : WOOD; let lv = iron ? Math.max(1, Math.min(2, b.lv)) : Math.max(2, Math.min(3, b.lv)); if (!has(0, -1) || !has(-1, 0)) lv = Math.min(iron ? 3 : 4, lv + 1); else if (!has(0, 1) || !has(1, 0)) lv = Math.max(0, lv - 1); return ramp[lv];}function piecePixels(p, tx, ty, deg, sy, relit) { const a = (deg * Math.PI) / 180, c = Math.cos(a), sn = Math.sin(a); const src = new Map(); const r = 14, bx = Math.round(tx), by = Math.round(ty); for (let y = by - r; y <= by + r; y++) for (let x = bx - r; x <= bx + r; x++) { if (x < 0 || y < 0 || x >= W || y >= H) continue; const dx = x + 0.5 - tx, dy = (y + 0.5 - ty) / sy; const lx = dx * c + dy * sn, ly = -dx * sn + dy * c; const b = p.m.get(`${Math.floor(lx + p.cx)},${Math.floor(ly + p.cy)}`); if (b) src.set(y * W + x, b); } const pix = new Map(); for (const [k, b] of src) { const x = k % W, y = (k - x) / W; pix.set(k, relit ? chunkKey(b, src, x, y) : b.key); } return pix;}// ---- effects ----------------------------------------------------------------------------------------------------function star(g, x, y, size) { g.detached("glint", () => { if (size === 0) { g.pixel(x, y, "n5"); return; } g.pixel(x, y, "n5"); for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) g.pixel(x + dx, y + dy, "n4"); });}function dust(g, f) { // two soft dust clouds roll out from the foot: each is three round clumps, a small n4 core in a checker of n3, // drifting outward and up, thinning to a sparse n2 dither and gone const k = f - 1; if (k < 0 || k > 4) return; const CLUMPS = [[0, 0, 2.0, 0], [-2.6, 0.7, 1.5, 1], [2.3, -1.0, 1.4, 1]]; // outward, down, radius, dither parity g.detached("dust", () => { const seen = new Set(); for (const s of [-1, 1]) for (const [ox, oy, r0, par] of CLUMPS) { const r = r0 + k * 0.4, cx = CX + s * (11.5 + k * 1.7 + ox), cy = GROUND - 1.2 - k * 0.8 + oy; for (let py = Math.floor(cy - r - 1); py <= cy + r + 1; py++) for (let px = Math.floor(cx - r - 1); px <= cx + r + 1; px++) { const d = Math.hypot(px + 0.5 - cx, (py + 0.5 - cy) * 1.1) / r; if (d > 1 || px < 0 || px >= W || py < 0 || py >= H || seen.has(py * W + px)) continue; const on = ((px + py + par) & 1) === 0; let key = null; if (k === 0) key = d < 0.5 ? "n4" : on ? "n3" : null; else if (k === 1) key = d < 0.35 ? "n4" : on ? (d < 0.7 ? "n3" : "n2") : null; else if (k === 2) key = on ? (d < 0.55 ? "n3" : "n2") : null; else if (k === 3) key = on && (px + 2 * py) % 3 !== 0 ? "n2" : null; else key = on && (px + 2 * py) % 4 === 0 ? "n2" : null; if (key) { seen.add(py * W + px); g.pixel(px, py, key); } } } });}// splinters: sticks flung from the impact and from every board that bursts (crate lid boards, brace)const SPLINTERS = [[-1, -0.9, 1], [1, -1.1, 1], [-0.6, -1.5, 0], [0.8, -0.6, 0], [0.2, -1.7, 1]];function splinters(g, s, f) { const k = f - 1; if (k < 0 || k > 3) return; const S = SHAPES[s], P = piecesFor(s); const origins = [[CX, S.cy, 0]]; for (const id of Object.keys(S.land)) if (S.land[id][7]) origins.push([P[id].cx, P[id].cy, origins.length]); g.detached("splinters", () => { for (const [ox, oy, o] of origins) SPLINTERS.forEach(([vx, vy, long], i) => { if (o > 0 && (i + o) % 2 === 1) return; // fewer from each burst board const tt = k + 1, sp = o > 0 ? 2.6 : 4.5; const x = Math.round(ox + vx * sp * tt + (o ? (o - 2) * 0.6 * tt : 0)), y = Math.round(oy + vy * sp * tt + 0.9 * tt * tt); if (x < 0 || x >= W || y < 0 || y >= H) return; const dx = vx > 0 ? 1 : -1, steep = Math.abs(vy) > 1.2; g.pixel(x, y, (i + o) % 2 ? "w4" : "w3"); if (long && k < 3) g.pixel(x - (steep ? 0 : dx), y + (steep ? 1 : 0), "w2"); if (k < 2 && o > 0) g.pixel(x - (steep ? 0 : dx) * 2, y + (steep ? 2 : 0), "w1"); }); });}function motes(g, t, pts) { g.detached("motes", () => { pts.forEach(([x0, y0, ph], i) => { const p = (t + ph) % 1; if (p > 0.75) return; const x = x0 + Math.round(Math.sin(TAU * (p + i * 0.3)) * 1), y = y0 + Math.round(p * 5); g.pixel(x, y, p < 0.4 ? "n4" : "n3"); }); });}// ---- per-shape setup: rest pose, hit keys, landing table ------------------------------------------------------const REST = { sx: 1, sy: 1, ang: 0, piv: 0, hop: 0, dx: 0 };const HIT_KEYS = [ // sx, sy, ang(deg), pivot side, dx [1.08, 0.9, 0, 0, 0], [0.96, 1.05, 7, 1, 1], [1, 1, -5, -1, 0], [1, 1, 2.5, 1, 0], [1.01, 0.99, -1, -1, 0], [1, 1, 0, 0, 0],];// landing: piece -> [x, y, spin, flat (y scale at rest), landing frame, peak, layer (draw order in the pile)]const SHAPES = { crate: { model: CRATE, pivot: CW, rock: 0.7, shadow: 11, shadowBroken: 15, cy: 18, land: { postR: [27, 21, 26, 1, 6, 2, 0], // leans on the heap at the right, behind it railT: [15, 18, 6, 0.8, 7, 3, 0], postL: [4, 24, 10, 0.6, 5, 2, 1], plankT: [11, 24, -22, 0.8, 5, 2, 2], plankB: [21, 25, 20, 0.8, 6, 3, 3], // crosses over plankT railB: [15, 30, -4, 0.7, 3, 0, 4], // these burst into splinters instead of landing topL: [10, 14, 0, 1, 9, 2, 9, true], topR: [22, 14, 0, 1, 9, 2, 9, true], brace: [16, 20, 0, 1, 9, 2, 9, true], }, teeter: { piece: "plankB", px: 16, py: 23 }, motes: [[9, 16, 0], [23, 15, 0.5]], }, barrel: { model: BARREL, pivot: 6.3, rock: 1, shadow: 10, shadowBroken: 14, cy: 18, land: { lid: [7, 17, -8, 1, 7, 6, 0], // lid disc, flat on the floor behind-left st2: [24, 17, 80, 0.6, 6, 4, 0], // widest stave group lying behind-right st4: [27, 22, 18, 1, 6, 3, 0], // leans on the stump at the right st0: [6, 27, -35, 0.7, 5, 2, 2], st3: [22, 30, 100, 0.5, 5, 2, 2], st1: [10, 16, 0, 1, 9, 2, 9, true], // bursts into splinters }, teeter: { piece: "st4", px: 28, py: 27 }, motes: [[10, 16, 0.2], [22, 17, 0.7]], },};const CACHE = {};function cached(key, fn) { return CACHE[key] || (CACHE[key] = fn()); }const intact = (s) => cached(s + ":intact", () => cast(SHAPES[s].model, REST, false));const baseOf = (s) => cached(s + ":base", () => (s === "barrel" ? cast(BARREL, REST, true) : null));const piecesFor = (s) => cached(s + ":pieces", () => piecesOf(intact(s)));function hitPose(s, f) { if (f === N_HIT - 1) return intact(s); const [sx, sy, deg, side, dx] = HIT_KEYS[f]; return cached(s + ":hit" + f, () => cast(SHAPES[s].model, { sx, sy, ang: (deg * SHAPES[s].rock * Math.PI) / 180, piv: side * SHAPES[s].pivot, hop: 0, dx }, false));}// crack frame: board seams go inkfunction crackOver(s) { return cached(s + ":cracks", () => { const buf = cast(SHAPES[s].model, { sx: 1.06, sy: 0.93, ang: 0, piv: 0, hop: 0, dx: 0 }, false); const m = new Map(); for (let i = 0; i < W * H; i++) { const b = buf[i]; if (!b || b.piece === "inside") continue; const r = buf[i + 1], d = buf[i + W]; if ((r && r.piece !== b.piece && r.piece !== "inside") || (d && d.piece !== b.piece && d.piece !== "inside")) m.set(i, b.m === "iron" ? "n1" : "w0"); } return { buf, m }; });}function pieceState(s, id, f) { const p = piecesFor(s)[id], L = SHAPES[s].land[id]; const [rx, ry, spin, flat, lf, peak] = L; if (f <= 0) return { x: p.cx, y: p.cy, a: 0, sy: 1, relit: false }; const u = Math.min(1, (f - 1) / (lf - 1)), e = 1 - (1 - u) * (1 - u); const ox = p.cx - CX, oy = p.cy - SHAPES[s].cy, ol = Math.hypot(ox, oy) || 1; const x0 = p.cx + (ox / ol) * 1.5, y0 = p.cy + (oy / ol) * 1.2; let x = x0 + (rx - x0) * e, y = y0 + (ry - y0) * u - peak * 4 * u * (1 - u); if (f === lf + 1 && peak > 0) y -= 1; return { x, y, a: spin * e, sy: 1 + (flat - 1) * u, relit: f > 1 };}function drawPile(g, occ, s, f, t) { const S = SHAPES[s], ids = Object.keys(S.land).filter((id) => !(S.land[id][7] && (f === null || f >= 2))); const base = baseOf(s); const st = (id) => { if (f === null) { const [x, y, a, sy] = S.land[id]; let ang = a; if (id === S.teeter.piece) ang += 4 * Math.sin(TAU * t); // the top board rocks on the pile return { x, y, a: ang, sy, relit: true }; } return pieceState(s, id, f); }; // pieces that have landed are layered as a pile; the rest fly in front const landed = (id) => f === null || f >= S.land[id][4]; const order = ids.slice().sort((a, b) => { const la = landed(a), lb = landed(b); if (la !== lb) return la ? -1 : 1; return la ? S.land[a][6] - S.land[b][6] || S.land[a][1] - S.land[b][1] : st(a).y - st(b).y; }); if (base) { for (const id of order) if (landed(id) && S.land[id][6] === 0) { const q = st(id); emitLayer(g, occ, id, piecePixels(piecesFor(s)[id], q.x, q.y, q.a, q.sy, q.relit), true); } drawBody(g, occ, base); } for (const id of order) { if (base && landed(id) && S.land[id][6] === 0) continue; const q = st(id); emitLayer(g, occ, id, piecePixels(piecesFor(s)[id], q.x, q.y, q.a, q.sy, q.relit), true); }}// idle glint: crate — a star blinks on the top-left iron plate; barrel — a glint slides along the upper hoopfunction idleOver(s, f) { const buf = intact(s), over = new Map(); if (s === "crate") return { over, star: f === 3 ? [7, 11, 1] : f === 2 || f === 4 ? [7, 11, 0] : null }; const hoopTop = []; for (let i = 0; i < W * H; i++) { const b = buf[i]; if (b && b.hoopIdx === 1 && b.lv >= 3) hoopTop.push(i); } hoopTop.sort((a, b) => (a % W) - (b % W)); const path = [null, 0, 0.2, 0.4, 0.6, null, null, null][f]; if (path !== null && hoopTop.length) { const i = hoopTop[Math.round(path * (hoopTop.length - 1))]; over.set(i, "n5"); if (buf[i + 1] && buf[i + 1].hoopIdx === 1) over.set(i + 1, "n4"); } return { over, star: null };}const Breakable = sprite(pal, (g, t, mode, s) => { const S = SHAPES[s], occ = new Uint8Array(W * H); const N = mode === "idle" ? N_IDLE : mode === "hit" ? N_HIT : mode === "break" ? N_BREAK : N_BROKEN; const f = mode === "idle" || mode === "broken" ? Math.round(t * N) % N : Math.min(N - 1, Math.round(t * N)); const spread = mode === "broken" || (mode === "break" && f >= 4); contactShadow(g, CX, GROUND, spread ? S.shadowBroken : S.shadow, 2); if (mode === "idle") { const { over, star: st } = idleOver(s, f); drawBody(g, occ, intact(s), over); if (st) star(g, st[0], st[1], st[2]); return; } if (mode === "hit") { drawBody(g, occ, hitPose(s, f)); if (f === 0) star(g, 29, 15, 1); if (f === 1) g.detached("chips", () => { g.pixel(29, 12, "w3"); g.pixel(30, 16, "w4"); g.pixel(28, 10, "w2"); }); return; } if (mode === "break") { if (f === 0) { const c = crackOver(s); drawBody(g, occ, c.buf, c.m); star(g, 29, 15, 1); return; } if (f === N_BREAK - 1) { drawPile(g, occ, s, null, 0); motes(g, 0, S.motes); return; } // == broken frame 0 drawPile(g, occ, s, f, 0); dust(g, f); splinters(g, s, f); return; } drawPile(g, occ, s, null, t); motes(g, t, S.motes);});const V = [];for (const s of ["crate", "barrel"]) { const suf = s === "crate" ? "" : "-barrel"; V.push({ name: "idle" + suf, args: ["idle", s], animate: { fps: 10, duration: N_IDLE / 10, loop: true } }); V.push({ name: "hit" + suf, args: ["hit", s], animate: { fps: 10, duration: N_HIT / 10, loop: false } }); V.push({ name: "break" + suf, args: ["break", s], animate: { fps: 10, duration: N_BREAK / 10, loop: false } }); V.push({ name: "broken" + suf, args: ["broken", s], animate: { fps: 10, duration: N_BROKEN / 10, loop: true } });}submit(Breakable, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, anchor: { x: CX, y: GROUND }, variants: V,});掲載されたコードでは、ビルドスクリプトが自動付与する共通部分を省略しています。具体的には 1 行のランタイムヘッダー、共有パレット、および汎用ヘルパー関数(contactShadow、litIndex、flame、hash、chk、TAU)です。表示されている行番号は実際のファイルと一致しています。これらのプログラムは AIエージェントが生成し、描画されたコマの仕上がりを確認しながら修正を加えたものです。
エージェントに、こうしたプログラムを書かせる。
Tessarune は描画 API のリファレンス、25 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。