Dungeon Mechanisms · flame_jet.js
火炎の噴出口
32×64 の床の罠で、切石を輪に並べた穴に鉄の格子を渡しています。噴出口は 32 ピクセルの床の部品で、炎の柱が収まるようにキャンバスを縦に長くしています。idle(8 コマ)は格子の下で穴がくすぶり、火の粉がいくつか昇って冷めます。burst(12 コマ)は噴出口のはるか上まで炎の柱が噴き上がり、先端から炎がちぎれます。炎の色は石の照り返しや火の粉も含めて一つの階調なので、idle-soul と burst-soul は同じループを魂火にしたものです。
6×
動くスプライト素材: Dungeon Mechanisms(itch.io)Vol.2・罠と扉と壊れる物 12 点
冒頭のコメント。これが指示書です。サイズ、視点、光の向き、すべての変種が書かれています。
flame_jet.js · 1–8
// Flame jet — 32x64 (the vent is a 32px floor piece; the canvas is tall so the fire column fits), top-down 3/4,// key light top-left. A ring of dressed stone blocks round a pit, an iron grate across the pit.// All fire colours are the fl0..3 family, so the whole jet (column, glow on the stone, embers) swaps with swap("fl", ...).// Variants (all 32x64, ground at the bottom so they align):// idle loop 8f — the pit smoulders under the grate, a few embers climb and cool// burst loop 12f — a roaring column of fire rises well above the vent, tongues tear off the top// burst-soul loop 12f — the same in soulfire (fl -> soul)// idle-soul loop 8f — the smouldering vent in soulfire, to pair with burst-soul最後の数行。submit でキャンバスの大きさと、描画エンジンが書き出す変種の一覧を指定します。
flame_jet.js · 183–193
submit(FlameJet, { size: [32, 64], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_IDLE / 10, loop: true }, anchor: { x: CX, y: GROUND }, variants: [ { name: "idle", args: ["idle"] }, { name: "burst", args: ["burst"], animate: { fps: 10, duration: N_BURST / 10, loop: true } }, { name: "burst-soul", args: ["burst"], animate: { fps: 10, duration: N_BURST / 10, loop: true }, repalette: swap("fl", "soul") }, { name: "idle-soul", args: ["idle"], repalette: swap("fl", "soul") }, ],});プログラム全体を表示 (193 行)
flame_jet.js · 193 行
// Flame jet — 32x64 (the vent is a 32px floor piece; the canvas is tall so the fire column fits), top-down 3/4,// key light top-left. A ring of dressed stone blocks round a pit, an iron grate across the pit.// All fire colours are the fl0..3 family, so the whole jet (column, glow on the stone, embers) swaps with swap("fl", ...).// Variants (all 32x64, ground at the bottom so they align):// idle loop 8f — the pit smoulders under the grate, a few embers climb and cool// burst loop 12f — a roaring column of fire rises well above the vent, tongues tear off the top// burst-soul loop 12f — the same in soulfire (fl -> soul)// idle-soul loop 8f — the smouldering vent in soulfire, to pair with burst-soulconst CX = 16, GROUND = 61;const RING = { cy: 53, rx: 14.5, ry: 6, side: 2 }; // top face of the stone ring; `side` = height of its outer wallconst HOLE = { cy: 53, rx: 8.5, ry: 3.3 }; // opening in the ring's top faceconst GRATE = { cy: 54, rx: 8, ry: 2.9 }; // the grate sits a little below the top faceconst N_IDLE = 8, N_BURST = 12;const eR = (x, y, cx, cy, rx, ry) => { const nx = (x + 0.5 - cx) / rx, ny = (y + 0.5 - cy) / ry; return Math.sqrt(nx * nx + ny * ny); };const inE = (x, y, cx, cy, rx, ry) => eR(x, y, cx, cy, rx, ry) <= 1;const inRing = (x, y) => inE(x, y, CX, RING.cy, RING.rx, RING.ry);const inHole = (x, y) => inE(x, y, CX, HOLE.cy, HOLE.rx, HOLE.ry);// ---- stone ring ---------------------------------------------------------------------------------const BLOCKS = 8;function blockAt(x, y) { // angle round the ring -> block index and position inside the block (0..1) const a = Math.atan2((y + 0.5 - RING.cy) / RING.ry, (x + 0.5 - CX) / RING.rx); const u = ((a / TAU) * BLOCKS + 0.5 + BLOCKS) % BLOCKS; return [Math.floor(u), u - Math.floor(u)];}function ring(g, hot) { const { cy, rx, ry, side } = RING; g.part("ring", () => { // outer wall (seen on the near half): lit on the left, turning away on the right, a dark foot on the floor g.field({ x: [CX - rx - 1, CX + rx + 1], y: [cy, cy + ry + side + 1] }, (x, y) => { if (inRing(x, y)) return null; let d = 0; for (let k = 1; k <= side; k++) if (inRing(x, y - k)) { d = k; break; } if (!d) return null; const [, f] = blockAt(x, y - d); const nx = (x + 0.5 - CX) / rx; if (f < 0.07 || f > 0.97) return "n0"; // joint between blocks if (d === 1) return nx < -0.45 ? "n3" : nx < 0.4 ? "n2" : "n1"; return nx < -0.45 ? "n1" : "n0"; }); // top face g.field({ x: [CX - rx - 1, CX + rx + 1], y: [cy - ry - 1, cy + ry + 1] }, (x, y) => { if (!inRing(x, y) || inHole(x, y)) return null; const [k, f] = blockAt(x, y); const r = eR(x, y, CX, cy, rx, ry), rh = eR(x, y, CX, HOLE.cy, HOLE.rx, HOLE.ry); const nx = (x + 0.5 - CX) / rx, ny = (y + 0.5 - cy) / ry; if (hot && rh < 1.16 && ny > -0.55) return "fl2"; // the fire lights the inner lip if (hot && rh < 1.36 && ny > 0.1) return "fl1"; if (f < 0.07 || f > 0.97) return "n1"; // joint if (r > 0.9) return nx + 0.6 * ny < 0.1 ? "n4" : "n3"; // outer arris if (rh < 1.16) return ny > 0 ? "n4" : "n2"; // inner arris: the near lip faces the light return k === 2 || k === 5 ? "n2" : "n3"; // two blocks sit a shade darker }); // chips on a few blocks (countable marks) for (const [x, y, c] of [[6, 50, "n2"], [25, 49, "n2"], [8, 57, "n2"], [24, 57, "n2"], [19, 58, "n2"]]) { if (!hot || !inE(x, y, CX, HOLE.cy, HOLE.rx * 1.36, HOLE.ry * 1.36)) g.pixel(x, y, c); } });}// ---- pit & grate --------------------------------------------------------------------------------function pit(g, glow) { g.part("pit", () => { g.field({ x: [CX - HOLE.rx, CX + HOLE.rx], y: [HOLE.cy - HOLE.ry - 1, HOLE.cy + HOLE.ry + 1] }, (x, y) => { if (!inHole(x, y)) return null; if (!inE(x, y, CX, GRATE.cy, GRATE.rx, GRATE.ry)) return glow(-1, x, y); // far inner wall return glow(eR(x, y, CX, GRATE.cy, GRATE.rx * 0.85, GRATE.ry), x, y); }); });}const isBar = (x, y) => y === GRATE.cy - 1 || y === GRATE.cy + 1 ? 2 : (x - CX + 30) % 3 === 0 ? 1 : 0;function grate(g, hot) { // two bars across and short bars front-to-back, clipped to the grate's ellipse g.part("grate", () => { g.field({ x: [CX - GRATE.rx, CX + GRATE.rx], y: [GRATE.cy - GRATE.ry - 1, GRATE.cy + GRATE.ry + 1] }, (x, y) => { if (!inE(x, y, CX, GRATE.cy, GRATE.rx, GRATE.ry)) return null; const b = isBar(x, y); if (!b) return null; if (hot) return b === 2 && (x - CX + 30) % 3 !== 0 ? "n0" : "ink"; // silhouettes against the fire if (b === 2) return (x - CX + 30) % 3 === 0 ? (x < CX + 2 ? "n3" : "n2") : x < CX - 2 ? "n2" : "n1"; return "n0"; }); });}// ---- fire ---------------------------------------------------------------------------------------// three flames rooted in the pit: a tall centre column and two short flankers leaning out. Each flame has nested// layers (shell, body, bright, core), each layer itself flame-shaped (shorter and narrower).const BASE = GRATE.cy + 1;const FLAMES = [ { name: "flankL", x: 11.5, h: 20, hw: 4.2, lean: -3.5, seed: 0.31 }, { name: "flankR", x: 20.5, h: 24, hw: 4.2, lean: 3.5, seed: 0.64 }, { name: "column", x: 16, h: 49, hw: 7.4, lean: 0, seed: 0.05 },];const LAYERS = [["fl0", 1, 1], ["fl1", 0.92, 0.72], ["fl2", 0.72, 0.48], ["fl3", 0.45, 0.25]];const profile = (k) => (k < 0.12 ? 0.82 + 0.18 * (k / 0.12) : k < 0.42 ? 1 - 0.14 * ((k - 0.12) / 0.3) : 0.86 * Math.pow((1 - k) / 0.58, 0.8));function flameH(f, t) { return f.h * (1 + 0.07 * Math.sin(TAU * (2 * t + f.seed)) + 0.04 * Math.sin(TAU * (3 * t + 2 * f.seed)));}function flameAxis(f, t, k) { return f.x + (f.lean + 1.5 * Math.sin(TAU * (t + f.seed))) * k * k + 1.1 * Math.sin(TAU * (1.3 * k - 2 * t + f.seed)) * k;}function layerOf(f, t, x, y) { // index of the innermost layer of flame f covering pixel (x, y); -1 = outside const k = (BASE + 1 - (y + 0.5)) / flameH(f, t); if (k < 0 || k > 1) return -1; const lick = 1 + 0.18 * Math.sin(TAU * (2.6 * k - 3 * t + 1.7 * f.seed)) + 0.07 * Math.sin(TAU * (5.2 * k - 2 * t)); const dx = Math.abs(x + 0.5 - flameAxis(f, t, k)); let li = -1; for (let j = 0; j < LAYERS.length; j++) { const [, hf, wf] = LAYERS[j], kj = k / hf; if (kj > 1 || dx > f.hw * wf * profile(kj) * lick) break; li = j; } return li;}function fire(g, t) { // the flames merge: each pixel takes the hottest layer any flame puts there, so no shell cuts through a core g.part("fire", () => g.field({ x: [0, 31], y: [0, BASE] }, (x, y) => { if (y >= RING.cy && !inHole(x, y)) return null; // the near half of the ring stands in front of the foot let li = -1; for (const f of FLAMES) li = Math.max(li, layerOf(f, t, x, y)); return li < 0 ? null : LAYERS[li][0]; }));}function tongues(g, t) { // blobs tear off the tip of the column and rise, shrinking const f = FLAMES[2]; g.detached("tongues", () => { for (let i = 0; i < 2; i++) { const p = (2 * t + i / 2) % 1; const tip = BASE - flameH(f, t); const x = Math.round(flameAxis(f, t, 1)) + (i ? 1 : -1); const y = Math.round(tip - p * 8); if (y < 1) continue; if (p < 0.4) { g.ellipse({ x: [x - 1, x + 1], y: [y - 2, y + 1] }, "fl0", { fill: "fl0" }); g.line([x, y - 1], [x, y], "fl1"); } else if (p < 0.7) { g.rect({ x: [x, x + 1], y: [y - 1, y] }, "fl0", { fill: "fl0" }); g.pixel(x, y, "fl1"); } else if (p < 0.9) g.pixel(x, y, "fl0"); } });}function embers(g, t, n, reach, y0, spread) { g.detached("embers", () => { for (let i = 0; i < n; i++) { const p = (t + i / n) % 1, s = hash(i, 5) - 0.5; const x = CX + Math.round(s * spread * (1 + p) + Math.sin(TAU * (p + hash(i, 2))) * 1.3); const y = y0 - Math.round(p * reach * (0.75 + 0.25 * hash(i, 8))); if (p < 0.85 && y >= 0) g.pixel(x, y, p < 0.25 ? "fl3" : p < 0.5 ? "fl2" : p < 0.7 ? "fl1" : "fl0"); } });}// ---- sprite -------------------------------------------------------------------------------------const FlameJet = sprite(pal, (g, t, state) => { const burst = state === "burst"; contactShadow(g, CX, GROUND, 16, 2); g.outlined("ink", () => ring(g, burst)); if (!burst) { // idle: the bed of embers under the grate breathes; a slow pulse travels across it pit(g, (r, x, y) => { const pulse = 0.5 + 0.5 * Math.sin(TAU * (t - x / 12)); if (r < 0) return (x + 0.5 - CX) / HOLE.rx > 0.35 ? "n1" : "n0"; // far inner wall if (r < 0.35) return pulse > 0.7 ? "fl2" : "fl1"; if (r < 0.62) return pulse > 0.45 ? "fl1" : "fl0"; if (r < 0.88) return pulse > 0.6 ? "fl0" : "n0"; return "ink"; }); grate(g, false); embers(g, t, 3, 11, GRATE.cy - 2, 8); return; } pit(g, (r) => (r < 0 ? "fl1" : r < 0.6 ? "fl3" : "fl2")); fire(g, t); grate(g, true); tongues(g, t); embers(g, t, 8, 34, 46, 12);});submit(FlameJet, { size: [32, 64], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_IDLE / 10, loop: true }, anchor: { x: CX, y: GROUND }, variants: [ { name: "idle", args: ["idle"] }, { name: "burst", args: ["burst"], animate: { fps: 10, duration: N_BURST / 10, loop: true } }, { name: "burst-soul", args: ["burst"], animate: { fps: 10, duration: N_BURST / 10, loop: true }, repalette: swap("fl", "soul") }, { name: "idle-soul", args: ["idle"], repalette: swap("fl", "soul") }, ],});掲載されたコードでは、ビルドスクリプトが自動付与する共通部分を省略しています。具体的には 1 行のランタイムヘッダー、共有パレット、および汎用ヘルパー関数(contactShadow、litIndex、flame、hash、chk、TAU)です。表示されている行番号は実際のファイルと一致しています。これらのプログラムは AIエージェントが生成し、描画されたコマの仕上がりを確認しながら修正を加えたものです。
エージェントに、こうしたプログラムを書かせる。
Tessarune は描画 API のリファレンス、25 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。