どのプログラムにも共通する形
Tessarune のプロシージャルなドット絵は、少数の呼び出しでできています。
- palette({ … })
- 名前付きの色。これらのパックは 32 色のパレットを共有し、4 段の階調に分けています。n0–n5 は鉄と石、w は木、g は真鍮、r は炎、e は緑、b は青、v は紫。
- sprite(pal, (g, t, …args) => …)
- 描画の関数。g は筆、t はループの中の時刻で 0 から 1 まで進みます。追加の引数で「点灯」「消灯」などの状態を選びます。
- g.part(name, …)
- 画素に名前を付けてまとめます。人もAIも、その部品だけを探し、確かめ、直せます。
- g.field(box, (x, y) => 色)
- 範囲内の画素ごとに、関数へ色を問い合わせます。曲面、炎、光の陰影はこの方法で塗っています。
- g.outlined("ink", …)
- 中で描いたもの全体の周りに 1px の輪郭線を引きます。
- submit(sprite, { size, animate, variants })
- キャンバスの大きさ、fps とループ、描き出す変種の一覧を宣言します。
掲載したコードでは、パックのビルドスクリプトが各プログラムの前に付け足す部分を省いています。1 行の runtime ヘッダー、共通パレット、いくつかの補助関数(contactShadow、litIndex、flame、hash、chk、TAU)です。行番号は実際のものです。プログラムは AIエージェントが書き、描かれたコマを確認しながら直しました。
大釜 — 陰影、ループ、3 色の薬液
Arcane Curiosities Vol.1 の基準になった小物です。ほかの小物が従う書き方がそろっています。左上からの光、1px の輪郭線、ディザーの接地影、そしてループごとにぴったり一周する動き。
6×


最初に依頼文、次に形。冒頭のコメントは、依頼をそのまま言葉で書いたものです。その下で、大釜はいくつかの楕円として数値で書かれ、IRON は胴体に使う 4 段の鉄の階調です。
// Cauldron over a wood fire — 48x48, top-down 3/4, key light top-left.// Loop: 12 frames @ 10 fps. Bubbles swell and pop, the brew swirls, the sigil pulses, steam drifts, fire flickers.// Variants: brew colour green / violet / blue (ramp swap — no new colours).const CX = 24, GROUND = 45;const BODY = { cx: 24, cy: 29, rx: 16, ry: 12 };const LIP = { cy: 16, rx: 17, ry: 6 };const MOUTH = { cy: 16, rx: 14, ry: 4 };const IRON = ["n0", "n1", "n2", "n3"];丸い胴を塗る。g.field は範囲内のすべての画素について色を問い合わせます。litIndex は胴を左上から照らされた球とみなして 0〜3 の段を返し、それで鉄の色が決まります。底には炎の暖かい照り返し、小さな楕円の判定でハイライトを置いています。
function body(g, t) { const { cx, cy, rx, ry } = BODY; g.field({ x: [cx - rx, cx + rx], y: [cy - ry, cy + ry] }, (x, y) => { const i = litIndex(x, y, cx, cy, rx, ry, 4, -0.05); if (i < 0) return null; const nx = (x + 0.5 - cx) / rx, ny = (y + 0.5 - cy) / ry; // warm rim light from the fire along the underside if (ny > 0.5 && Math.abs(nx) < 0.55 && edgeDist(x, y, cx, cy, rx, ry) < 1.2) return Math.abs(nx) < 0.3 ? "r1" : "r0"; // soft specular: a short curved highlight on the upper-left shoulder const sx = nx + 0.5, sy = ny + 0.28; if (sx * sx * 3 + sy * sy * 9 < 0.07) return "n4"; return IRON[i]; });}閉じるループ。泡はそれぞれ位相を持ち、(t + ph) % 1 はループごとに 0 から 1 まで一度だけ進みます。点、ふくらみ、しぶきの輪、そして消える。最後のコマは跳ばずに最初のコマへつながります。
function bubbles(g, t) { const spots = [[-6, 1, 0.0], [3, 2, 0.34], [8, 0, 0.68]]; g.detached("bubbles", () => { for (const [dx, dy, ph] of spots) { const p = (t + ph) % 1; const x = CX + dx, y = MOUTH.cy + dy; if (p < 0.2) g.pixel(x, y, "brew2"); else if (p < 0.45) { g.rect({ x: [x - 1, x], y: [y - 1, y] }, "brew2", { fill: "brew2" }); g.pixel(x - 1, y - 1, "brew3"); } else if (p < 0.62) { g.ellipse({ x: [x - 2, x + 1], y: [y - 2, y] }, "brew2", { fill: "brew2" }); g.pixel(x - 1, y - 2, "brew3"); g.pixel(x - 2, y - 1, "brew3"); g.pixel(x + 1, y, "brew0"); } else if (p < 0.75) { for (const [ox, oy] of [[-3, 0], [2, 0], [-1, -3], [0, 1]]) g.pixel(x + ox, y + oy, "brew3"); } } });}組み立てる。部品は奥から順に描き、固いものは g.outlined("ink") の中に置きます。submit でキャンバス、1.2 秒を 10 fps、足元の基準点、そして 3 つの変種を宣言します。swap("brew", "v") は薬液の色を紫の階調へ移すだけ。色も増えず、描き直しもありません。
const Cauldron = sprite(pal, (g, t) => { contactShadow(g, CX, GROUND, 18, 2); g.outlined("ink", () => { foot(g, 12, true); foot(g, 36, false); g.part("body", () => body(g, t)); lip(g); }); band(g, t); brew(g, t); bubbles(g, t); g.outlined("ink", () => { logs(g); fire(g, t); }); steam(g, t);});submit(Cauldron, { size: [48, 48], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: 1.2, loop: true }, anchor: { x: 24, y: 45 }, variants: [ { name: "idle-green" }, { name: "idle-violet", repalette: swap("brew", "v") }, { name: "idle-blue", repalette: swap("brew", "b") }, ],});プログラム全体を表示 (202 行)
// Cauldron over a wood fire — 48x48, top-down 3/4, key light top-left.// Loop: 12 frames @ 10 fps. Bubbles swell and pop, the brew swirls, the sigil pulses, steam drifts, fire flickers.// Variants: brew colour green / violet / blue (ramp swap — no new colours).const CX = 24, GROUND = 45;const BODY = { cx: 24, cy: 29, rx: 16, ry: 12 };const LIP = { cy: 16, rx: 17, ry: 6 };const MOUTH = { cy: 16, rx: 14, ry: 4 };const IRON = ["n0", "n1", "n2", "n3"];function edgeDist(x, y, cx, cy, rx, ry) { // approximate distance (px) from the ellipse boundary, inside only const nx = (x + 0.5 - cx) / rx, ny = (y + 0.5 - cy) / ry; const r = Math.sqrt(nx * nx + ny * ny); return (1 - r) * Math.min(rx, ry) * 1.15;}function body(g, t) { const { cx, cy, rx, ry } = BODY; g.field({ x: [cx - rx, cx + rx], y: [cy - ry, cy + ry] }, (x, y) => { const i = litIndex(x, y, cx, cy, rx, ry, 4, -0.05); if (i < 0) return null; const nx = (x + 0.5 - cx) / rx, ny = (y + 0.5 - cy) / ry; // warm rim light from the fire along the underside if (ny > 0.5 && Math.abs(nx) < 0.55 && edgeDist(x, y, cx, cy, rx, ry) < 1.2) return Math.abs(nx) < 0.3 ? "r1" : "r0"; // soft specular: a short curved highlight on the upper-left shoulder const sx = nx + 0.5, sy = ny + 0.28; if (sx * sx * 3 + sy * sy * 9 < 0.07) return "n4"; return IRON[i]; });}function band(g, t) { // riveted iron band following the belly's curvature, with a pulsing sigil plate at the front const { cx, cy, rx } = BODY; const yAt = (x) => { const u = (x + 0.5 - cx) / (rx - 0.5); return Math.round(cy - 3 + (1 - Math.sqrt(Math.max(0, 1 - u * u))) * -3); }; g.part("band", () => { for (let x = cx - rx + 1; x <= cx + rx - 1; x++) { const y = yAt(x), u = (x - cx) / rx; g.pixel(x, y, u < -0.3 ? "n3" : u < 0.5 ? "n2" : "n1"); g.pixel(x, y + 1, "n0"); } for (const u of [-0.8, -0.45, 0.45, 0.8]) { const x = Math.round(cx + u * (rx - 1)); g.pixel(x, yAt(x) + 1, u < 0 ? "n4" : "n3"); } }); const pulse = 0.5 + 0.5 * Math.cos(TAU * t); const hot = pulse > 0.66 ? "brew3" : pulse > 0.33 ? "brew2" : "brew1"; const y0 = yAt(cx) - 1; g.part("sigil", () => { g.fillPoly([[cx, y0 - 1], [cx + 3, y0 + 2], [cx, y0 + 5], [cx - 3, y0 + 2]], "n1"); g.poly([[cx, y0 - 1], [cx + 3, y0 + 2], [cx, y0 + 5], [cx - 3, y0 + 2], [cx, y0 - 1]], "g1"); g.pixel(cx - 1, y0, "g2"); g.pixel(cx - 2, y0 + 1, "g2"); g.pixel(cx, y0 + 1, "brew1"); g.pixel(cx - 1, y0 + 2, "brew1"); g.pixel(cx + 1, y0 + 2, "brew1"); g.pixel(cx, y0 + 3, "brew1"); g.pixel(cx, y0 + 2, hot); if (pulse > 0.5) { g.pixel(cx, y0 + 1, "brew2"); g.pixel(cx, y0 + 3, "brew2"); } });}function lip(g) { const { cy, rx, ry } = LIP; g.part("lip", () => { // outer thickness (seen on the near side) then the lit top face g.ellipse({ x: [CX - rx, CX + rx], y: [cy - ry + 1, cy + ry] }, "n1", { fill: "n1" }); g.field({ x: [CX - rx, CX + rx], y: [cy - ry, cy + ry - 1] }, (x, y) => { const nx = (x + 0.5 - CX) / rx, ny = (y + 0.5 - (cy - 0.5)) / (ry - 0.5); if (nx * nx + ny * ny > 1) return null; const a = -0.7 * nx - 0.3 * ny; // top-left of the ring catches the light return a > 0.45 ? "n4" : a > 0.05 ? "n3" : a > -0.45 ? "n2" : "n1"; }); }); for (const s of [-1, 1]) { const hx = CX + s * (rx + 1); g.part(s < 0 ? "handleL" : "handleR", () => { g.strokeEllipse({ x: [hx - 2, hx + 2], y: [cy + 4, cy + 9] }, s < 0 ? "n2" : "n1"); g.pixel(hx - 1, cy + 4, s < 0 ? "n4" : "n2"); }); }}function brew(g, t) { const { cy, rx, ry } = MOUTH; const L = { cx: CX, cy: cy + 1, rx: rx - 1, ry: ry - 1 }; g.part("brew", () => { // inner wall of the far side, then the liquid surface g.field({ x: [CX - rx, CX + rx], y: [cy - ry, cy + ry] }, (x, y) => { const nx = (x + 0.5 - CX) / rx, ny = (y + 0.5 - cy) / ry; if (nx * nx + ny * ny > 1) return null; const lx = (x + 0.5 - L.cx) / L.rx, ly = (y + 0.5 - L.cy) / L.ry; if (lx * lx + ly * ly > 1) return nx > 0.35 ? "n1" : "n0"; // inner wall: its right side catches light if (ly > 0.55 && Math.abs(lx) < 0.8) return "brew2"; // meniscus along the near edge if (ly < -0.3) return "brew0"; return "brew1"; }); // swirl: two arms, 2-fold symmetric so a half turn per loop closes the loop for (let arm = 0; arm < 2; arm++) { for (let s = 0.25; s < 0.9; s += 0.09) { const a = Math.PI * t + arm * Math.PI + s * 2.8; const x = Math.round(L.cx + Math.cos(a) * s * L.rx); const y = Math.round(L.cy + Math.sin(a) * s * L.ry); g.pixel(x, y, s > 0.6 ? "brew2" : "brew0"); } } g.line([CX - 9, cy], [CX - 6, cy], "brew3"); // glint of the key light });}function bubbles(g, t) { const spots = [[-6, 1, 0.0], [3, 2, 0.34], [8, 0, 0.68]]; g.detached("bubbles", () => { for (const [dx, dy, ph] of spots) { const p = (t + ph) % 1; const x = CX + dx, y = MOUTH.cy + dy; if (p < 0.2) g.pixel(x, y, "brew2"); else if (p < 0.45) { g.rect({ x: [x - 1, x], y: [y - 1, y] }, "brew2", { fill: "brew2" }); g.pixel(x - 1, y - 1, "brew3"); } else if (p < 0.62) { g.ellipse({ x: [x - 2, x + 1], y: [y - 2, y] }, "brew2", { fill: "brew2" }); g.pixel(x - 1, y - 2, "brew3"); g.pixel(x - 2, y - 1, "brew3"); g.pixel(x + 1, y, "brew0"); } else if (p < 0.75) { for (const [ox, oy] of [[-3, 0], [2, 0], [-1, -3], [0, 1]]) g.pixel(x + ox, y + oy, "brew3"); } } });}function steam(g, t) { g.detached("steam", () => { for (let i = 0; i < 4; i++) { const p = (t + i / 4) % 1; const x0 = CX + [-6, -1, 4, 8][i] + Math.round(Math.sin(TAU * (p * 0.8 + i * 0.3)) * 2); const y0 = LIP.cy - 4 - Math.round(p * 12); if (p < 0.35) { g.rect({ x: [x0, x0 + 1], y: [y0, y0 + 1] }, "brew3", { fill: "brew3" }); g.pixel(x0 + 1, y0 + 1, "brew2"); } else if (p < 0.7) { g.pixel(x0, y0, "brew2"); g.pixel(x0 + 1, y0 - 1, "brew2"); } else if (chk(x0, Math.floor(t * 12))) g.pixel(x0, y0, "brew1"); } });}function logs(g) { g.part("logs", () => { // back log (angled), front log (level) with end grain facing the viewer g.rect({ x: [18, 31], y: [39, 41] }, "w1", { fill: "w1" }); g.pixel(31, 39, "w3"); g.rect({ x: [15, 34], y: [41, 44] }, "w2", { fill: "w2" }); g.line([16, 41], [33, 41], "w3"); g.line([16, 44], [33, 44], "w1"); g.ellipse({ x: [12, 16], y: [40, 45] }, "w3", { fill: "w3" }); g.strokeEllipse({ x: [12, 16], y: [40, 45] }, "w1"); g.pixel(14, 42, "w1"); g.pixel(13, 41, "w4"); g.line([22, 42], [26, 42], "w1"); g.pixel(30, 43, "w1"); });}function fire(g, t) { g.part("fire", () => { flame(g, 19, 40, 5, 8, t, 0.1, ["r1", "r2", "r3", "g3"]); flame(g, 29, 40, 5, 7, t, 0.55, ["r1", "r2", "r3", "g3"]); flame(g, 24, 41, 7, 11, t, 0.3, ["r1", "r2", "r3", "g3"]); }); g.detached("embers", () => { for (let k = 0; k < 3; k++) { const p = (t * 2 + k / 3) % 1; const x = 24 + Math.round(Math.sin(TAU * (p + k * 0.4)) * 7), y = 37 - Math.round(p * 5); if (p < 0.7) g.pixel(x, y, p < 0.35 ? "g2" : "r2"); } });}function foot(g, fx, lit) { g.part(lit ? "footL" : "footR", () => { g.fillPoly([[fx - 2, 37], [fx + 2, 37], [fx + 3, 43], [fx - 3, 43]], "n1"); g.line([fx - 2, 38], [fx - 3, 42], lit ? "n3" : "n2"); g.line([fx - 3, 43], [fx + 3, 43], "n0"); g.pixel(fx - 1, 43, "n2"); g.pixel(fx + 1, 43, "n2"); // toes });}const Cauldron = sprite(pal, (g, t) => { contactShadow(g, CX, GROUND, 18, 2); g.outlined("ink", () => { foot(g, 12, true); foot(g, 36, false); g.part("body", () => body(g, t)); lip(g); }); band(g, t); brew(g, t); bubbles(g, t); g.outlined("ink", () => { logs(g); fire(g, t); }); steam(g, t);});submit(Cauldron, { size: [48, 48], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: 1.2, loop: true }, anchor: { x: 24, y: 45 }, variants: [ { name: "idle-green" }, { name: "idle-violet", repalette: swap("brew", "v") }, { name: "idle-blue", repalette: swap("brew", "b") }, ],});壁の松明 — 一つの関数から 6 つのアニメーション
炎の色が 4 種類、消灯した状態、点火のアニメーションを持つ壁の小物です。状態は一つの描画関数の引数なので、松明そのものは一度しか描いていません。
6×



状態の一覧が冒頭に。コメントに、すべての変種とその長さが並んでいます。一回再生の点火は、点灯ループの最初のコマで終わるので、ゲームでは続けて再生できます。
// 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)入れ子の 4 つの涙形で炎を作る。LAYERS は外殻・本体・明部・芯の 4 層です。drawFlame は画素ごとに、炎のどの高さか、揺れる軸からどれだけ離れているかを測り、内側にある層ほど優先します。flameH は t の 2 つの正弦波で炎の高さを揺らします。
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; }));}点火は表で書く。GROW は 8 コマそれぞれの炎の大きさです。なし、火花、成長、少し行き過ぎて、落ち着く。g.depth で、松明の手前の部品を壁の板より前に保っています。
// ---- 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];⋯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); }});1 本のプログラムから 6 つの変種。炎・魂火・魔女火・胆汁の火は、炎の色 fl の階調の差し替えです。消灯と点火は、別の状態と別のタイミングを渡しています。
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 — 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 } }, ],});転送の紋 — ソースの中で読める紋字
休眠・起動・作動の 3 つの状態を持つ床の紋です。紋字そのものは、小さな文字の絵として書かれています。
6×


3×3 の文字で書いた紋字。一つの紋字は 3 本の文字列で、x が光る画素です。12 の枠に 4 つの紋字を繰り返し並べているので、帯を 4 枠(120°)回すと同じ絵に戻ります。作動中のループが閉じる仕組みはこれです。
// ---- runes: twelve slots, four glyphs repeating, so turning by four slots (120 deg) closes the loop ------const GLYPHS = [ ["xx.", "x.x", "x.."], // thorn ["x.x", ".x.", ".x."], // elk [".x.", "x.x", ".x."], // ing ["x.x", "xxx", "x.x"], // hagal];const runeAt = (k, off) => { const a = (k * 30 + off) * DEG; return { x: Math.round(CX + Math.cos(a) * RX * S_MID), y: Math.round(CY + Math.sin(a) * RY * S_MID), a: ((k * 30 + off) % 360 + 360) % 360 };};押し当てる。runeAt が各枠の楕円上の位置を求め、紋字の x が、今の状態が指定する色の画素になります。
function runes(g, off, lit) { g.part("runes", () => { for (let k = 0; k < 12; k++) { const r = runeAt(k, off), gl = GLYPHS[k % 4], key = lit(r.a, k); if (!key) continue; for (let j = 0; j < 3; j++) for (let i = 0; i < 3; i++) if (gl[j][i] === "x") g.pixel(r.x - 1 + i, r.y - 1 + j, key); } });}光を規則の集まりとして書く。作動中の状態は、小さな関数を集めたオブジェクトです。溝・中央の印・紋字が、ある角度と時刻に何色になるか。2 つの光の脈が、半周離れて紋字と一緒に回ります。
function activeLight(t) { const p1 = (90 + 120 * t) % 360, p2 = (p1 + 180) % 360; // two pulses ride round with the runes return { groove: (x, y, a, c) => { if (c === G2) return adist(a, (270 - 120 * t + 360) % 360) < 20 ? "glow3" : "glow2"; return adist(a, p1) < 14 || adist(a, p2) < 14 ? "glow3" : "glow2"; }, sigil: () => "glow3", rune: () => "glow3", spill: true, pool: ["glow1", "glow2"], };}状態を選ぶ。休眠、作動、コマ単位で書いた起動のどれも、同じ台座・紋字・光の柱・光の粒へ流れ込みます。起動の最後のコマは、作動中のループへ引き継がれます。
const Rune = sprite(pal, (g, t, state) => { let L, off = 0, ct = t, colH = 0, bright = false, age = 1; if (state === "dormant") { L = dormantLight(t); age = -1; } else if (state === "active") { L = activeLight(t); off = 120 * t; colH = 34; } else { // activate: frame-indexed const f = Math.round(t * ACT); ct = ((f - (ACT - 1)) / 12 + 1) % 1; // same 10 fps clock as the active loop off = SPIN[f] % 360; if (f >= ACT - 1) L = activeLight(0); else L = activateLight(f); colH = [0, 0, 0, 0, 0, 22, 40, 36, 34, 34][f]; bright = f === 5 || f === 6; age = f >= 7 ? (f - 6) * 0.34 : -1; if (f < ACT - 1) spark(g, f); } contactShadow(g, CX, GROUND, 23, 2); g.outlined("ink", () => dais(g, L)); curbMarks(g); runes(g, off, (a) => L.rune(a)); if (colH > 0) column(g, ct, colH, bright); if (age > 0) motes(g, ct, age);});プログラム全体を表示 (254 行)
// Teleport rune — 48x48 floor prop, top-down 3/4, key light top-left.// A low round dais of flagstones: a ring of curb stones, a carved channel inlaid with light, a recessed band of// twelve runes, an inner channel, and a compass-star sigil cut through the four inner flagstones.// Variants (all light in glow0..3; violet = swap("glow","v")):// dormant 12f loop runes barely lit; a slow shimmer walks once round the outer channel// activate 10f once a spark races round the channel lighting every rune, the runes whirl up, the sigil flares// and a column of light bursts up; ends on active frame 0// active 12f loop bright channels, the rune band turns (4 runes per loop), motes rise through a light columnconst W = 48, H = 48, CX = 24, CY = 30, RX = 22, RY = 13, GROUND = 45;const S_G1 = 0.87, S_G2 = 0.46; // outer / inner channel (ellipse scale)const S_MID = (S_G1 + S_G2) / 2; // rune band centrelineconst DEG = Math.PI / 180;// ---- static per-pixel map -----------------------------------------------------------------------------const NONE = 0, FACE = 1, CURB = 2, G1 = 3, BAND = 4, G2 = 5, INNER = 6;const rOf = (x, y) => Math.hypot((x - CX) / RX, (y - CY) / RY);const inS = (x, y, s) => rOf(x, y) <= s - 0.01; // the margin trims the 1px nubs at the polesconst ringPx = (x, y, s) => inS(x, y, s) && [[1, 0], [-1, 0], [0, 1], [0, -1]].some(([a, b]) => !inS(x + a, y + b, s));const code = new Uint8Array(W * H), ang = new Float32Array(W * H);for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) { const i = y * W + x; ang[i] = ((Math.atan2((y - CY) / RY, (x - CX) / RX) / DEG) + 360) % 360; // 0 = right, 90 = front if (!inS(x, y, 1)) { code[i] = y > CY && (inS(x, y - 1, 1) || inS(x, y - 2, 1)) ? FACE : NONE; continue; } code[i] = ringPx(x, y, S_G1) ? G1 : inS(x, y, S_G1) === false ? CURB : ringPx(x, y, S_G2) ? G2 : inS(x, y, S_G2) ? INNER : BAND;}const at = (x, y) => (x < 0 || y < 0 || x >= W || y >= H ? NONE : code[y * W + x]);// band pixels touching a channel: they catch spill light when active; the channel's upper-left lip shades the bandconst nearG = (x, y) => [[1, 0], [-1, 0], [0, 1], [0, -1]].some(([a, b]) => { const c = at(x + a, y + b); return c === G1 || c === G2; });// angular distance (deg, 0..180)const adist = (a, b) => { const d = Math.abs(((a - b) % 360 + 360) % 360); return Math.min(d, 360 - d); };// swept(a, from, len): is angle a inside the arc that starts at `from` and runs `len` degrees clockwise?const swept = (a, from, len) => ((a - from) % 360 + 360) % 360 <= len;// ---- runes: twelve slots, four glyphs repeating, so turning by four slots (120 deg) closes the loop ------const GLYPHS = [ ["xx.", "x.x", "x.."], // thorn ["x.x", ".x.", ".x."], // elk [".x.", "x.x", ".x."], // ing ["x.x", "xxx", "x.x"], // hagal];const runeAt = (k, off) => { const a = (k * 30 + off) * DEG; return { x: Math.round(CX + Math.cos(a) * RX * S_MID), y: Math.round(CY + Math.sin(a) * RY * S_MID), a: ((k * 30 + off) % 360 + 360) % 360 };};// ---- stone -------------------------------------------------------------------------------------------const stoneHash = (a) => hash(Math.floor(((a + 15) % 360) / 30), 5); // one value per curb stonefunction dais(g, L) { g.part("dais", () => g.field({ x: [1, 46], y: [15, 46] }, (x, y) => { const c = at(x, y), i = y * W + x, a = ang[i]; const nx = (x - CX) / RX; if (c === NONE) return null; if (c === FACE) { // the dais' side: lit on the left, falling away on the right const top = !inS(x, y - 1, 1) ? false : true; if (nx < -0.62) return top ? "n2" : "n1"; if (nx > 0.55) return "n0"; return top ? "n1" : "n0"; } if (c === CURB) { const edge = !inS(x - 1, y, 1) || !inS(x, y - 1, 1) || !inS(x + 1, y, 1) || !inS(x, y + 1, 1); if (edge && a > 150 && a < 290) return "n4"; // outer lip catches the light (upper left) if (edge && a > 10 && a < 110) return "n2"; // lower right lip in shade return stoneHash(a) < 0.3 ? "n2" : "n3"; } if (c === G1 || c === G2) return L.groove(x, y, a, c); if (c === BAND) { if (L.spill && nearG(x, y)) return "glow0"; // carved band: the upper-left wall of the channel shades the band just inside it const shade = at(x, y - 1) === G1 || (at(x - 1, y) === G1 && a > 180); return shade ? "n0" : "n1"; } // INNER: four flagstones cut by a glowing cross; a diamond sigil at the centre const dx = x - CX, dy = y - CY, r = rOf(x, y); const ring = ringPx(x, y, 0.24) && !(dx === 0 && dy === 0); const spoke = r > 0.26 && Math.abs(Math.abs(dx) / RX - Math.abs(dy) / RY) < 0.024; // the flagstone seams if (dx === 0 && dy === 0) return L.sigil(x, y, true); if (ring || spoke) return L.sigil(x, y, false); if (L.pool) return r < 0.24 ? L.pool[1] : L.pool[0]; // flat flagstones; the lower-right lip of each cut seam faces the light const cut = (u, v) => ringPx(u, v, 0.24) || (rOf(u, v) > 0.26 && Math.abs(Math.abs(u - CX) / RX - Math.abs(v - CY) / RY) < 0.024) || at(u, v) === G2; return cut(x - 1, y) || cut(x, y - 1) ? "n3" : "n2"; }));}function curbMarks(g) { g.part("curbSeams", () => { for (let j = 0; j < 12; j++) { const a = (j * 30 + 15) * DEG, c = Math.cos(a), s = Math.sin(a); const p0 = [Math.round(CX + c * RX * 0.92), Math.round(CY + s * RY * 0.92)]; const p1 = [Math.round(CX + c * RX * 0.99), Math.round(CY + s * RY * 0.99)]; g.line(p0, p1, "n1"); if (s > 0.2) { const fx = Math.round(CX + c * RX); const fy = Math.round(CY + s * RY) + 1; g.line([fx, fy], [fx, fy + 1], "ink"); } } g.pixel(8, 23, "n4"); g.pixel(37, 20, "n2"); g.pixel(41, 38, "n1"); g.pixel(12, 40, "n4"); // chips });}// ---- light layers --------------------------------------------------------------------------------------function runes(g, off, lit) { g.part("runes", () => { for (let k = 0; k < 12; k++) { const r = runeAt(k, off), gl = GLYPHS[k % 4], key = lit(r.a, k); if (!key) continue; for (let j = 0; j < 3; j++) for (let i = 0; i < 3; i++) if (gl[j][i] === "x") g.pixel(r.x - 1 + i, r.y - 1 + j, key); } });}const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];function column(g, t, hgt, bright) { // an opaque shaft of light standing on the inner disc: hot core, softer body, dim edges; bright bands (seen as // near arcs of a ring) climb it, a few sparks streak up, and the top dissolves in a dither. Periodic in t. const RC = bright ? 9 : 7; const TONE = ["glow1", "glow2", "glow3", "n5"]; g.detached("column", () => g.field({ x: [CX - 10, CX + 10], y: [0, CY + 6] }, (x, y) => { const h0 = CY - y; // height above the disc centre const wob = 1 + 0.04 * Math.sin(TAU * (h0 / 9 - 2 * t)); // the shaft breathes as it rises const dx = Math.abs(x - CX) / (RC * wob); if (dx > 1) return null; const arc = Math.sqrt(Math.max(0, 1 - dx * dx)); const yb = CY + Math.round(4.8 * arc); // front edge of the base const h = yb - y; // height above the floor if (h < 0 || h > hgt) return null; const top = hgt - h; // distance below the column's top const fade = top / (hgt * 0.45); // ordered-dither dissolve over the top 45% if (fade < 1 && BAYER[(y & 3) * 4 + (x & 3)] / 16 >= fade * fade * 0.9 + 0.05) return null; let k = dx < 0.3 ? 2 : dx < 0.72 ? 1 : 0; if (bright) k = dx < 0.8 ? 2 : 1; if (h === 0) k = Math.max(k, 2); // hot seam where the beam meets the floor for (let j = 0; j < 3; j++) { // bright bands climbing, curved like the near arc of a ring const hr = ((t + j / 3) % 1) * (hgt - 2); const d = y - (CY - Math.round(hr) + Math.round(2 * arc)); if (d === 0 || d === 1) { k += 1; break; } } if (!bright && dx > 0.3 && dx < 0.72 && hash(x, 11) > 0.5) { // sparks streaking up the body const s = ((y + Math.round(t * 24) + Math.floor(hash(x, 13) * 12)) % 12 + 12) % 12; if (s < 2) k = Math.max(k, 2); } return TONE[Math.min(3, k)]; }));}function motes(g, t, maxAge) { g.detached("motes", () => { for (let i = 0; i < 9; i++) { const p = (t * (i % 3 === 0 ? 2 : 1) + hash(i, 21)) % 1; if (p > maxAge) continue; const a = (hash(i, 23) * 360) * DEG, rr = 0.55 + 0.35 * hash(i, 29); const x = Math.round(CX + Math.cos(a) * RX * rr + Math.sin(TAU * (p + hash(i, 31))) * 1.2); const y = Math.round(CY + Math.sin(a) * RY * rr - p * 22); if (y < 0) continue; const key = p < 0.35 ? "glow3" : p < 0.7 ? "glow2" : "glow1"; g.pixel(x, y, key); if (p < 0.15) { g.pixel(x, y + 1, "glow2"); } } });}// ---- states --------------------------------------------------------------------------------------------function dormantLight(t) { const head = (90 + 360 * t) % 360; // the shimmer walks once round per loop return { groove: (x, y, a, c) => { if (c === G2) return "glow0"; const d = ((head - a) % 360 + 360) % 360; // how far behind the shimmer's head return d < 6 ? "glow3" : d < 22 ? "glow2" : d < 45 ? "glow1" : "glow0"; }, sigil: (x, y, centre) => (centre ? "glow1" : "glow0"), rune: (a) => (adist(a, head - 12) < 16 ? "glow2" : "glow1"), spill: false, pool: null, };}function activeLight(t) { const p1 = (90 + 120 * t) % 360, p2 = (p1 + 180) % 360; // two pulses ride round with the runes return { groove: (x, y, a, c) => { if (c === G2) return adist(a, (270 - 120 * t + 360) % 360) < 20 ? "glow3" : "glow2"; return adist(a, p1) < 14 || adist(a, p2) < 14 ? "glow3" : "glow2"; }, sigil: () => "glow3", rune: () => "glow3", spill: true, pool: ["glow1", "glow2"], };}const ACT = 10;const SPIN = [0, 5, 15, 30, 50, 70, 88, 102, 112, 120]; // rune whirl during activation (ends = 4 slots)function activateLight(f) { const d = dormantLight(0); const len = Math.min(360, 72 * (f + 1)); // the racing spark's swept arc from the front const head = (90 + len) % 360; const lit = (a) => len >= 360 || swept(a, 90, len); return { groove: (x, y, a, c) => { if (c === G2) return f >= 5 ? "glow2" : f === 4 ? "glow1" : "glow0"; if (len < 360 && adist(a, head) < 9) return "glow3"; if (f === 4 && len >= 360) return "glow3"; // the ring closes with a flash return lit(a) ? "glow2" : d.groove(x, y, a, c); }, sigil: (x, y, centre) => (f >= 5 ? "glow3" : f === 4 ? "glow2" : d.sigil(x, y, centre)), rune: (a) => (len < 360 && adist(a, head) < 20 ? "glow3" : lit(a) ? (f >= 4 ? "glow3" : "glow2") : "glow1"), spill: f >= 5, pool: f >= 6 ? ["glow1", "glow2"] : f === 5 ? ["glow0", "glow1"] : null, };}function spark(g, f) { // the spark at the head of the racing light: a small star if (f > 3) return; const a = (90 + 72 * (f + 1)) * DEG; const x = Math.round(CX + Math.cos(a) * RX * S_G1), y = Math.round(CY + Math.sin(a) * RY * S_G1); g.detached("spark", () => { for (const [ox, oy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) g.pixel(x + ox, y + oy, "glow3"); g.pixel(x, y - 2, "glow2"); g.pixel(x, y, "n5"); });}const Rune = sprite(pal, (g, t, state) => { let L, off = 0, ct = t, colH = 0, bright = false, age = 1; if (state === "dormant") { L = dormantLight(t); age = -1; } else if (state === "active") { L = activeLight(t); off = 120 * t; colH = 34; } else { // activate: frame-indexed const f = Math.round(t * ACT); ct = ((f - (ACT - 1)) / 12 + 1) % 1; // same 10 fps clock as the active loop off = SPIN[f] % 360; if (f >= ACT - 1) L = activeLight(0); else L = activateLight(f); colH = [0, 0, 0, 0, 0, 22, 40, 36, 34, 34][f]; bright = f === 5 || f === 6; age = f >= 7 ? (f - 6) * 0.34 : -1; if (f < ACT - 1) spark(g, f); } contactShadow(g, CX, GROUND, 23, 2); g.outlined("ink", () => dais(g, L)); curbMarks(g); runes(g, off, (a) => L.rune(a)); if (colH > 0) column(g, ct, colH, bright); if (age > 0) motes(g, ct, age);});submit(Rune, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: 1.2, loop: true }, anchor: { x: CX, y: GROUND }, args: ["dormant"], variants: [ { name: "dormant" }, { name: "activate", args: ["activate"], animate: { fps: 10, duration: 1.0, loop: false } }, { name: "active", args: ["active"] }, { name: "dormant-violet", repalette: swap("glow", "v") }, { name: "activate-violet", args: ["activate"], animate: { fps: 10, duration: 1.0, loop: false }, repalette: swap("glow", "v") }, { name: "active-violet", args: ["active"], repalette: swap("glow", "v") }, ],});棘の罠 — 数値の表で書くアニメーション
8 本の棘は一つの仕組みです。どの棘も、陰影を付けた同じ円錐を、見える長さだけ変えて描いています。
6×

4 つの状態、一つの仕組み。待機・作動・構え・収納は、同じ 8 本の棘を違う高さで描いたものです。
// Spike trap — 32x32 floor tile, top-down 3/4, key light top-left.// A riveted iron plate set into the floor: a lit frame round a recessed panel pierced by 8 holes (3-2-3, staggered).// The spikes are one rig: each spike is a shaded cone whose visible length h slides out of its hole, so every// state is the same spikes at a different h, occluded back row to front row.// Variants:// idle loop 8f — spikes hidden; a faint glint winks from hole to hole// trigger one-shot 8f — spikes shoot up past full height, dust puffs out from the plate, spikes settle (== armed f0)// armed loop 8f — spikes up; a glint runs along the tips left to right// retract one-shot 6f — spikes sink back into the holes (== idle f0)const CX = 16, GROUND = 29;const PLATE = { x0: 2, x1: 30, y0: 8, y1: 26 }; // top face; the front face is 2px below itconst FULL = 7; // full spike length (px above the hole)const N_LOOP = 8, N_TRIG = 8, N_RET = 6;動きは数値の並び。TRIG は作動の 8 コマそれぞれの、棘の高さと土ぼこりの段階です。0、次に 9(本来の高さ 7 を越える)、6 に戻り、8、そして 7 で落ち着く。RET で沈めます。勢いの調整は、この数値を直すことです。
// ---- states -------------------------------------------------------------------------------------// trigger: spikes (h), dust stage per frameconst TRIG = [[0, 0], [9, 1], [6, 2], [8, 3], [7, 4], [7, 0], [7, 0], [7, 0]];const RET = [7, 6, 4, 1, 0, 0];描画関数の中の状態。状態ごとに t からコマを選び、棘の高さを決め、光や土ぼこりを足します。submit で、2 つのループと 2 つの一回再生にそれぞれのタイミングを与えています。
const SpikeTrap = sprite(pal, (g, t, state) => { contactShadow(g, CX, GROUND, 15, 2); g.outlined("ink", () => plate(g)); if (state === "idle") { const f = Math.round(t * N_LOOP) % N_LOOP; holes(g, holeGlint(f)); return; } holes(g, null); if (state === "armed") { const f = Math.round(t * N_LOOP) % N_LOOP; // frame 0 rests; frames 1..7 the glint walks the tips by rank const lit = (i) => f >= 1 && sweep(RANK[i]) === f; spikes(g, () => FULL, (i) => (lit(i) ? "n5" : null)); g.detached("glint", () => { HOLES.forEach((h, i) => { if (f >= 1 && sweep(RANK[i]) === f && (RANK[i] === 7 || sweep(RANK[i] + 1) !== f)) star(g, h.x, h.y + 1 - (FULL - 1), 1); }); }); return; } if (state === "trigger") { const f = Math.min(N_TRIG - 1, Math.round(t * N_TRIG)); const [h, d] = TRIG[f]; spikes(g, () => h); dust(g, d); return; } const f = Math.min(N_RET - 1, Math.round(t * N_RET)); spikes(g, () => RET[f]);});submit(SpikeTrap, { size: [32, 32], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_LOOP / 10, loop: true }, anchor: { x: CX, y: GROUND }, variants: [ { name: "idle", args: ["idle"] }, { name: "trigger", args: ["trigger"], animate: { fps: 10, duration: N_TRIG / 10, loop: false } }, { name: "armed", args: ["armed"] }, { name: "retract", args: ["retract"], animate: { fps: 10, duration: N_RET / 10, loop: false } }, ],});プログラム全体を表示 (207 行)
// Spike trap — 32x32 floor tile, top-down 3/4, key light top-left.// A riveted iron plate set into the floor: a lit frame round a recessed panel pierced by 8 holes (3-2-3, staggered).// The spikes are one rig: each spike is a shaded cone whose visible length h slides out of its hole, so every// state is the same spikes at a different h, occluded back row to front row.// Variants:// idle loop 8f — spikes hidden; a faint glint winks from hole to hole// trigger one-shot 8f — spikes shoot up past full height, dust puffs out from the plate, spikes settle (== armed f0)// armed loop 8f — spikes up; a glint runs along the tips left to right// retract one-shot 6f — spikes sink back into the holes (== idle f0)const CX = 16, GROUND = 29;const PLATE = { x0: 2, x1: 30, y0: 8, y1: 26 }; // top face; the front face is 2px below itconst FULL = 7; // full spike length (px above the hole)const N_LOOP = 8, N_TRIG = 8, N_RET = 6;// holes: [cx, top row y] back row first; 3-2-3 staggeredconst ROWS = [ { y: 11, xs: [8, 16, 24] }, { y: 16, xs: [12, 20] }, { y: 21, xs: [8, 16, 24] },];const HOLES = [];for (const r of ROWS) for (const x of r.xs) HOLES.push({ x, y: r.y });// rank of each hole in a diagonal sweep from the top-left (glint order)const RANK = new Array(HOLES.length);HOLES.map((h, i) => [h.x + 1.4 * h.y, i]).sort((a, b) => a[0] - b[0]).forEach((a, r) => { RANK[a[1]] = r; });// ---- plate --------------------------------------------------------------------------------------function plate(g) { const { x0, x1, y0, y1 } = PLATE; g.part("plate", () => { g.field({ x: [x0, x1], y: [y0, y1 + 2] }, (x, y) => { if (y > y1) { // front face (thickness): lit at the left end if (y === y1 + 2) return x < x0 + 3 ? "n1" : "n0"; return x < x0 + 3 ? "n2" : "n1"; } const dx0 = x - x0, dx1 = x1 - x, dy0 = y - y0, dy1 = y1 - y; if (dx0 < 2 || dx1 < 2 || dy0 < 2 || dy1 < 2) { // raised frame: top & left bands face the light, the right and front bands turn away if (dy0 === 0) return dx1 < 2 ? "n3" : "n4"; if (dx0 === 0) return dy1 < 2 ? "n3" : "n4"; if (dx1 === 0) return "n1"; if (dy1 === 0) return "n1"; if (dx0 === 1 || dy0 === 1) return dx1 < 2 ? "n2" : "n3"; return "n2"; } // recessed panel: the frame shades its top and left walls, the lower lip catches the light if (dy0 === 2) return "n0"; if (dx0 === 2) return "n0"; if (dy1 === 2) return "n3"; if (dx1 === 2) return "n2"; // a soft falloff: the panel is a touch brighter toward the top-left return dx0 + 1.4 * dy0 < 15 ? "n2" : "n1"; }); // rivets on the frame corners and mid-sides for (const [x, y] of [[x0 + 1, y0 + 1], [x1 - 2, y0 + 1], [x0 + 1, y1 - 1], [x1 - 2, y1 - 1], [CX - 1, y0 + 1], [CX - 1, y1 - 1]]) { g.pixel(x, y, x < CX && y < y1 - 1 ? "n5" : "n4"); g.pixel(x + 1, y, "n1"); } }); g.part("stain", () => { // old blood dried round one hole on the front row for (const [x, y, k] of [[17, 25, "r0"], [18, 25, "w0"], [14, 25, "w0"], [19, 22, "r0"], [19, 23, "w0"]]) g.pixel(x, y, k); });}function holes(g, glint) { g.part("holes", () => { HOLES.forEach((h, i) => { const { x, y } = h; g.pixel(x - 1, y, "ink"); g.pixel(x, y, "ink"); g.pixel(x + 1, y, "n0"); g.pixel(x - 1, y + 1, "n0"); g.pixel(x, y + 1, "ink"); g.pixel(x + 1, y + 1, "n0"); g.line([x - 1, y + 2], [x + 1, y + 2], "n3"); // lower lip catches the light const k = glint ? glint(i) : 0; // a hidden tip catching the light if (k > 0) g.pixel(x, y + 1, k > 1 ? "n4" : "n3"); }); });}// ---- spikes -------------------------------------------------------------------------------------// cone profile from the tip down: keys for columns cx-1, cx, cx+1 (null = empty); lit on the leftfunction spikeRow(k) { if (k === 0) return [null, "n5", null]; if (k === 1) return [null, "n4", null]; if (k < 3) return ["n4", "n2", null]; return ["n4", "n3", "n1"];}function spikePixels(cx, hy, h, tip, blood) { // h = visible length above the hole; hy = the hole's top row (the spike stands in its lower row) const base = hy + 1, n = Math.round(h), out = []; for (let r = 0; r < n; r++) { const y = base - (n - 1) + r, row = spikeRow(r); const collar = r === n - 1 && n > 2; // where it leaves the hole: a darker band for (let c = 0; c < 3; c++) { let key = row[c]; if (!key) continue; if (collar) key = c === 0 ? "n2" : c === 1 ? "n1" : "n0"; if (r <= 1 && tip) key = tip; if (blood && r >= 2 && r <= 3) key = c === 0 ? "r1" : "r0"; if (blood && r === 4 && c === 1) key = "r0"; out.push([cx + c - 1, y, key]); } } return { base, px: out };}function spikes(g, hs, tipAt) { // each spike carries its own ink outline, drawn back row to front row, so a front spike is cut out against the // ones behind it (g.outlined only rings empty pixels; here the spikes stand over the plate) g.part("spikes", () => HOLES.forEach((hole, i) => { const h = hs(i); if (Math.round(h) <= 0) return; const { base, px } = spikePixels(hole.x, hole.y, h, tipAt ? tipAt(i) : null, i === 6); const has = new Set(px.map(([x, y]) => x + "," + y)); for (const [x, y] of px) for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { const qx = x + dx, qy = y + dy; if (qy > base || has.has(qx + "," + qy)) continue; g.pixel(qx, qy, "ink"); } for (const [x, y, k] of px) g.pixel(x, y, k); }));}function star(g, x, y, size) { g.pixel(x, y, "n5"); for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) g.pixel(x + dx, y + dy, size > 1 ? "n5" : "n4"); if (size > 1) for (const [dx, dy] of [[2, 0], [-2, 0], [0, -2]]) g.pixel(x + dx, y + dy, "n4");}// ---- dust ---------------------------------------------------------------------------------------// puffs thrown out from under the plate's edges; stage 1..4: burst, billow, thin out, last motesconst PUFFS = [[3, 21, -1], [28, 19, 1], [7, 28, -1], [24, 28, 1]];const CLOUD = [ null, [" 54 ", " 4443"], [" 544 ", "544443", " 3443 "], [" 4 4 ", "4 4 43", " 3 3 "], [" 3 ", "3 3", " 3 "],];function dust(g, stage) { if (stage <= 0 || stage > 4) return; const rows = CLOUD[stage]; g.detached("dust", () => { for (const [px, py, s] of PUFFS) { const w = rows[0].length, x0 = Math.max(0, Math.min(31 - w, px + s * (stage - 1) - (w >> 1))); const y0 = py - rows.length + 1 - Math.floor((stage - 1) / 2); rows.forEach((row, j) => { for (let i = 0; i < row.length; i++) { const c = row[i]; if (c !== " ") g.pixel(x0 + i, y0 + j, "n" + c); } }); } });}// ---- states -------------------------------------------------------------------------------------// trigger: spikes (h), dust stage per frameconst TRIG = [[0, 0], [9, 1], [6, 2], [8, 3], [7, 4], [7, 0], [7, 0], [7, 0]];const RET = [7, 6, 4, 1, 0, 0];const sweep = (rank) => 1 + Math.floor((rank * 7) / HOLES.length); // frame (1..7) in which a rank is litfunction holeGlint(f) { // frame 0 rests (so the one-shots can join it); frames 1..7 the glint crosses the holes diagonally if (f < 1) return null; return (i) => (sweep(RANK[i]) === f ? 2 : sweep(RANK[i]) === f - 1 ? 1 : 0);}const SpikeTrap = sprite(pal, (g, t, state) => { contactShadow(g, CX, GROUND, 15, 2); g.outlined("ink", () => plate(g)); if (state === "idle") { const f = Math.round(t * N_LOOP) % N_LOOP; holes(g, holeGlint(f)); return; } holes(g, null); if (state === "armed") { const f = Math.round(t * N_LOOP) % N_LOOP; // frame 0 rests; frames 1..7 the glint walks the tips by rank const lit = (i) => f >= 1 && sweep(RANK[i]) === f; spikes(g, () => FULL, (i) => (lit(i) ? "n5" : null)); g.detached("glint", () => { HOLES.forEach((h, i) => { if (f >= 1 && sweep(RANK[i]) === f && (RANK[i] === 7 || sweep(RANK[i] + 1) !== f)) star(g, h.x, h.y + 1 - (FULL - 1), 1); }); }); return; } if (state === "trigger") { const f = Math.min(N_TRIG - 1, Math.round(t * N_TRIG)); const [h, d] = TRIG[f]; spikes(g, () => h); dust(g, d); return; } const f = Math.min(N_RET - 1, Math.round(t * N_RET)); spikes(g, () => RET[f]);});submit(SpikeTrap, { size: [32, 32], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_LOOP / 10, loop: true }, anchor: { x: CX, y: GROUND }, variants: [ { name: "idle", args: ["idle"] }, { name: "trigger", args: ["trigger"], animate: { fps: 10, duration: N_TRIG / 10, loop: false } }, { name: "armed", args: ["armed"] }, { name: "retract", args: ["retract"], animate: { fps: 10, duration: N_RET / 10, loop: false } }, ],});こういうプログラムを、AIエージェントに書かせてみませんか。
Tessarune は、描画命令のリファレンス、25 本の制作マニュアル、そして書いたものを確かめる描画エンジンをAIに渡します。