Dungeon Mechanisms · wall_torch.js
壁の松明
32×48 の壁掛けの小物で、3/4 視点、接地影はありません。鋲を打った鉄の壁板と輪の金具が木の松明を支え、布を巻いてタールを染ませた先端は三つ叉の鉄の受けに収まり、その上で炎が燃えています。lit-fire、lit-soul、lit-witch、lit-bile は炎の階調を差し替えただけの 12 コマのループです。unlit(消灯、8 コマ)は焦げた先端から細い煙が上がり、ignite(点火、8 コマの一回再生)は火花から炎が育って lit-fire の最初のコマで終わります。
6×
動くスプライト素材: Dungeon Mechanisms(itch.io)Vol.2・罠と扉と壊れる物 12 点
冒頭のコメント。これが指示書です。サイズ、視点、光の向き、すべての変種が書かれています。
wall_torch.js · 1–5
// Wall torch — 32x48 WALL prop (no contact shadow), 3/4 view, key light top-left.// A riveted iron wall plate; a collar ring holds a wooden torch whose tarred, cloth-wrapped head sits in a// three-prong iron cup. A living flame (layered teardrops + side tongues + embers) burns on top.// Variants: lit-fire / lit-soul / lit-witch / lit-bile (12f loop, fl ramp swap), unlit (8f loop: a thin smoke// wisp from the charred head), ignite (8f one-shot: a spark catches, the flame grows; ends on lit-fire frame 0).最後の数行。submit でキャンバスの大きさと、描画エンジンが書き出す変種の一覧を指定します。
wall_torch.js · 182–195
submit(WallTorch, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: 1.2, loop: true }, anchor: { x: 16, y: 46 }, args: ["lit"], variants: [ { name: "lit-fire" }, { name: "lit-soul", repalette: swap("fl", "soul") }, { name: "lit-witch", repalette: swap("fl", "witch") }, { name: "lit-bile", repalette: swap("fl", "bile") }, { name: "unlit", args: ["unlit"], animate: { fps: 10, duration: 0.8, loop: true } }, { name: "ignite", args: ["ignite"], animate: { fps: 10, duration: 0.8, loop: false } }, ],});プログラム全体を表示 (195 行)
wall_torch.js · 195 行
// Wall torch — 32x48 WALL prop (no contact shadow), 3/4 view, key light top-left.// A riveted iron wall plate; a collar ring holds a wooden torch whose tarred, cloth-wrapped head sits in a// three-prong iron cup. A living flame (layered teardrops + side tongues + embers) burns on top.// Variants: lit-fire / lit-soul / lit-witch / lit-bile (12f loop, fl ramp swap), unlit (8f loop: a thin smoke// wisp from the charred head), ignite (8f one-shot: a spark catches, the flame grows; ends on lit-fire frame 0).const W = 32, H = 48, CX = 16;const HEAD = { top: 19, bot: 27, hw0: 4.9, hw1: 2.5 }; // cloth head flares upward: half-width at top / bottomconst BASE = 19; // flame root (head top)// ---- iron wall plate (a small shield-shaped escutcheon behind the collar) --------------------------const PLATE = [[11, 29], [21, 29], [22, 30], [22, 39], [16, 45], [10, 39], [10, 30]];function plate(g) { g.part("plate", () => { g.fillPoly(PLATE, "n2"); g.line([11, 29], [20, 29], "n4"); g.line([10, 30], [10, 38], "n3"); // lit bevel, top and left g.line([22, 30], [22, 38], "n1"); g.line([21, 40], [17, 44], "n1"); // shaded bevel, right and lower right g.line([11, 40], [15, 44], "n3"); g.line([18, 34], [18, 40], "n1"); // the handle's cast shadow for (const [x, y] of [[12, 31], [20, 31], [16, 42]]) { g.pixel(x, y, "n4"); g.pixel(x + 1, y + 1, "n0"); } g.pixel(13, 37, "n1"); g.pixel(20, 36, "n3"); // a pit and a scuff });}// ---- torch ----------------------------------------------------------------------------------------function handle(g) { g.part("handle", () => { g.sweep([[16, 27], [16, 43]], { w0: 1.9, w1: 1.2 }, (u, v) => (v < -0.35 ? "w3" : v > 0.35 ? "w1" : "w2")); g.pixel(16, 38, "w1"); g.pixel(16, 39, "w1"); g.pixel(15, 36, "w4"); // grain, a lit knot });}function ringBack(g) { g.part("collarBack", () => { g.line([13, 31], [19, 31], "n1"); g.pixel(12, 32, "n2"); g.pixel(20, 32, "n1"); });}function ringFront(g) { g.part("collar", () => { g.line([12, 33], [20, 33], "n2"); g.line([13, 34], [19, 34], "n1"); g.line([12, 33], [14, 33], "n4"); g.pixel(15, 34, "n3"); // lit shoulder g.pixel(19, 34, "n0"); g.pixel(20, 33, "n1"); });}function head(g, lit, t) { const hwAt = (y) => HEAD.hw0 + (HEAD.hw1 - HEAD.hw0) * Math.pow((y - HEAD.top) / (HEAD.bot - HEAD.top), 0.8); const TWINE = [22, 25]; g.part("head", () => g.field({ x: [10, 22], y: [HEAD.top, HEAD.bot] }, (x, y) => { const hw = hwAt(y), u = (x + 0.5 - CX) / hw; if (Math.abs(u) > 1) return null; if (y === HEAD.top) { // charred crown where the fire sits if (!lit) return Math.abs(u) < 0.6 ? "n0" : "w0"; return Math.abs(u) < 0.7 ? "fl1" : "fl0"; } if (TWINE.includes(y)) return u < -0.4 ? "w4" : u < 0.35 ? "w3" : "w2"; // twine bindings // tarred cloth wrapped in slanted strips, the cone lit from the left const s = (y - HEAD.top + 1.3 * u) / 1.6; const seam = s - Math.floor(s) < 0.34; let k = u < -0.4 ? 2 : u < 0.45 ? 1 : 0; if (seam) k = Math.max(0, k - 1); if (y === HEAD.top + 1 && lit && Math.abs(u) < 0.95) k = Math.min(3, k + 1); // underglow from the flame return ["w0", "w1", "w2", "w3"][k]; })); if (lit) g.part("headEmbers", () => { // embers smoulder in the top wrap for (const [x, ph] of [[13, 0.1], [16, 0.45], [19, 0.8]]) { const p = 0.5 + 0.5 * Math.sin(TAU * (t + ph)); g.pixel(x, HEAD.top + 1, p > 0.55 ? "fl2" : "fl0"); } }); else g.part("ash", () => { g.pixel(14, HEAD.top, "n2"); g.pixel(18, HEAD.top, "n1"); g.pixel(17, HEAD.top + 1, "n0"); });}// ---- flame (layered teardrops, like the brazier) ---------------------------------------------------const LAYERS = [["fl0", 1, 1], ["fl1", 0.86, 0.72], ["fl2", 0.6, 0.48], ["fl3", 0.34, 0.26]];const profile = (k) => (k < 0.22 ? 0.72 + 0.28 * Math.sin((k / 0.22) * Math.PI / 2) : Math.pow((1 - k) / 0.78, 0.9));const FLAMES = [ { name: "tongueL", x: 13.2, base: BASE, h: 7, hw: 1.9, lean: -1.6, seed: 0.37 }, { name: "tongueR", x: 18.8, base: BASE, h: 8, hw: 1.9, lean: 1.6, seed: 0.71 }, { name: "flame", x: 16, base: BASE + 1, h: 18, hw: 4.8, lean: 0, seed: 0.08 },];const flameH = (f, t, s) => s * f.h * (1 + 0.09 * Math.sin(TAU * (2 * t + f.seed)) + 0.05 * Math.sin(TAU * (3 * t + 2 * f.seed)));function drawFlame(g, f, t, s) { const hh = flameH(f, t, s), hwS = f.hw * Math.min(1, 0.45 + 0.55 * s); if (hh < 1.5) return; g.part(f.name, () => g.field({ x: [2, 29], y: [Math.max(0, f.base - 24), f.base] }, (x, y) => { const k = (f.base + 1 - (y + 0.5)) / hh; if (k < 0 || k > 1) return null; const ax = f.x + 0.5 + (f.lean + 1.2 * Math.sin(TAU * (t + f.seed))) * k * k + 0.9 * Math.sin(TAU * (1.2 * k - 2 * t + f.seed)) * k; const lick = 1 + 0.2 * Math.sin(TAU * (2.3 * k - 2 * t + 1.7 * f.seed)); const dx = Math.abs(x + 0.5 - ax); let key = null; for (const [c, hf, wf] of LAYERS) { const kj = k / hf; if (kj > 1) break; if (dx <= hwS * wf * profile(kj) * lick) key = c; else break; } return key; }));}function fire(g, t, s) { for (const f of FLAMES) if (s > 0.55 || f.name === "flame") drawFlame(g, f, t, f.name === "flame" ? s : (s - 0.55) / 0.45); const main = FLAMES[2]; g.detached("flicks", () => { // tongues tear off the tip and rise if (s < 0.8) return; const p = (2 * t + 0.3) % 1, tip = main.base - flameH(main, t, s); const x = main.x + Math.round(1.2 * Math.sin(TAU * (t + main.seed))), y = Math.round(tip - 1 - p * 5); if (y < 0) return; if (p < 0.4) { g.pixel(x, y, "fl1"); g.pixel(x, y - 1, "fl0"); } else if (p < 0.7) g.pixel(x, y, "fl0"); }); g.detached("embers", () => { if (s < 0.6) return; for (let i = 0; i < 3; i++) { const p = (t + i / 3) % 1; const x = CX + Math.round((i - 1) * 3 + Math.sin(TAU * (p * 1.3 + hash(i, 4))) * 2); const y = BASE - 6 - Math.round(p * 14); if (p < 0.85 && y >= 0) g.pixel(x, y, p < 0.3 ? "fl3" : p < 0.6 ? "fl2" : "fl1"); } });}// ---- smoke wisp (unlit) -----------------------------------------------------------------------------function smoke(g, t, fade) { g.detached("smoke", () => { for (let y = BASE - 1; y >= 3; y--) { const k = (BASE - 1 - y) / 15; // 0 at the head, 1 at the top if (k > fade) continue; const x = CX + Math.round(Math.sin(TAU * (k * 1.4 - t)) * (0.4 + 2.2 * k) + 1.5 * k); const puff = (k * 2 - t + 2) % 1; // puffs travel upward; gaps between them if (puff > 0.78 && k > 0.15) continue; if (k > 0.55 && chk(x, y)) continue; // the upper wisp thins to a dither g.pixel(x, y, k < 0.3 ? "n3" : "n2"); } });}// ---- ignite: spark -> ember -> flame grows (frame-indexed; the last frame is lit-fire t=0) -----------const IGN = 8;const GROW = [0, 0.14, 0.3, 0.5, 0.72, 1.12, 1.04, 1];function spark(g, f) { g.detached("spark", () => { if (f === 0) { const [x, y] = [18, 16]; for (const [ox, oy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) g.pixel(x + ox, y + oy, "fl2"); g.pixel(x + 2, y, "fl0"); g.pixel(x - 2, y, "fl0"); g.pixel(x, y - 2, "fl0"); g.pixel(x, y, "fl3"); g.pixel(20, 14, "fl1"); g.pixel(22, 12, "fl0"); // the trail it came in on } else if (f <= 3) { // kindling sparks spit upward for (let i = 0; i < 3; i++) { const x = CX + [-3, 1, 3][i] + (f - 1) * [-1, 0, 1][i], y = BASE - 3 - f * 3 - i * 2; g.pixel(x, y, f < 3 ? "fl2" : "fl1"); } } });}const WallTorch = sprite(pal, (g, t, state) => { let lit = state === "lit", grow = 1, ft = t, f = 0; if (state === "ignite") { f = Math.round(t * IGN); grow = GROW[f]; lit = f >= 1; ft = ((f - (IGN - 1)) / 12 + 1) % 1; // same 10 fps clock as the lit loop } // nearer parts first at depth -1, so their ink outline survives the plate drawn behind them g.depth(-1, () => { g.outlined("ink", () => { g.depth(1, () => ringBack(g)); handle(g); ringFront(g); head(g, lit, ft); }); }); g.depth(1, () => g.outlined("ink", () => plate(g))); if (lit) g.depth(-2, () => fire(g, ft, grow)); if (state === "unlit") smoke(g, t, 1); if (state === "ignite") { if (f <= 2) smoke(g, 0, [1, 0.6, 0.3][f]); spark(g, f); }});submit(WallTorch, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: 1.2, loop: true }, anchor: { x: 16, y: 46 }, args: ["lit"], variants: [ { name: "lit-fire" }, { name: "lit-soul", repalette: swap("fl", "soul") }, { name: "lit-witch", repalette: swap("fl", "witch") }, { name: "lit-bile", repalette: swap("fl", "bile") }, { name: "unlit", args: ["unlit"], animate: { fps: 10, duration: 0.8, loop: true } }, { name: "ignite", args: ["ignite"], animate: { fps: 10, duration: 0.8, loop: false } }, ],});掲載されたコードでは、ビルドスクリプトが自動付与する共通部分を省略しています。具体的には 1 行のランタイムヘッダー、共有パレット、および汎用ヘルパー関数(contactShadow、litIndex、flame、hash、chk、TAU)です。表示されている行番号は実際のファイルと一致しています。これらのプログラムは AIエージェントが生成し、描画されたコマの仕上がりを確認しながら修正を加えたものです。
エージェントに、こうしたプログラムを書かせる。
Tessarune は描画 API のリファレンス、25 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。