Tessarune
Tavern & Market · street_lantern.js

街灯

32×64 · JavaScript 242 行

背の高い 32×64 の床置きの小物で、見下ろし 3/4 視点です。段のある台座、真鍮の輪、渦巻きの腕木を持つ鋳鉄の街灯柱に、真鍮の飾りを載せた角錐の笠と六角形のガラスのランタンがあり、中の真鍮の火口でガスの炎が燃えます。lit(点灯、12 コマのループ)は炎が揺れ、ガラスも一緒に明るさが変わり、点描の光の輪が脈打ち、蛾が 2 匹回ります。unlit(消灯、16 コマのループ)は暗いガラスを 1 ループに一度かすかな光が横切ります。light(点火、8 コマの一回再生)は火口に火花が散り、炎が付いて燃え上がります。炎の色を一式差し替えると、青い魂火の lit-soul と light-soul になります。

街灯 — 描画結果6×
このプログラムで描画(変種「lit」) · 32×64 · 12 コマ
Tavern & Market動くスプライト素材: Tavern & Market(itch.io)Vol.3・酒場と市場の小物 12 点・32 アニメ

冒頭のコメント。これが指示書です。サイズ、視点、光の向き、すべての変種が書かれています。

street_lantern.js · 1–8
// Street lantern — 32x64 floor prop (tall), top-down 3/4, key light top-left.// A cast-iron lamp post: stepped plinth, turned foot, brass collar, scroll brackets, and a six-sided glass lantern// (three panes face the viewer) under a pyramid cap with a brass finial. A gas flame burns on a brass burner.// Variants (flame colours in fl0..3, so swap("fl","soul") makes a blue magical lamp)://   lit       12f loop  the flame flickers, the panes breathe with it, a dithered halo pulses, two moths circle//   unlit      16f loop  dark glass; once per loop a faint glint slides across the panes//   light      8f once   a spark at the burner, the flame catches and flares, the glass fills; ends on lit frame 0//   lit-soul / light-soul  the same with a blue soul flame

最後の数行。submit でキャンバスの大きさと、描画エンジンが書き出す変種の一覧を指定します。

street_lantern.js · 230–242
submit(Lantern, {  size: [W, H], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: 1.2, loop: true },  anchor: { x: CX, y: GROUND },  args: ["lit"],  variants: [    { name: "lit" },    { name: "unlit", args: ["unlit"], animate: { fps: 10, duration: 1.6, loop: true } },    { name: "light", args: ["light"], animate: { fps: 10, duration: 0.8, loop: false } },    { name: "lit-soul", repalette: swap("fl", "soul") },    { name: "light-soul", args: ["light"], animate: { fps: 10, duration: 0.8, loop: false }, repalette: swap("fl", "soul") },  ],});
プログラム全体を表示 (242 行)
street_lantern.js · 242 行
// Street lantern — 32x64 floor prop (tall), top-down 3/4, key light top-left.// A cast-iron lamp post: stepped plinth, turned foot, brass collar, scroll brackets, and a six-sided glass lantern// (three panes face the viewer) under a pyramid cap with a brass finial. A gas flame burns on a brass burner.// Variants (flame colours in fl0..3, so swap("fl","soul") makes a blue magical lamp)://   lit       12f loop  the flame flickers, the panes breathe with it, a dithered halo pulses, two moths circle//   unlit      16f loop  dark glass; once per loop a faint glint slides across the panes//   light      8f once   a spark at the burner, the flame catches and flares, the glass fills; ends on lit frame 0//   lit-soul / light-soul  the same with a blue soul flameconst W = 32, H = 64, CX = 16, GROUND = 61;const GT = 15, GB = 24;                                  // glass rowsconst glassHW = (y) => 5.6 - (y - GT) * (1.4 / (GB - GT)); // panes flare wider at the topconst FC = { x: 16, y: 20 };                              // light centre (flame)// ---- iron ------------------------------------------------------------------------------------------------// cylinder/prism shading across a half-width: left lit, right shadedconst ironAt = (u) => (u < -0.45 ? "n3" : u < 0.3 ? "n2" : "n1");function taperRows(g, rows) {  // rows: [y, hw, style] — turned iron parts drawn row by row, shaded across  for (const [y, hw, top] of rows) {    for (let x = CX - hw; x <= CX + hw; x++) {      const u = hw ? (x - CX) / hw : 0;      let k = ironAt(u);      if (top) k = u < -0.3 ? "n4" : u < 0.5 ? "n3" : "n2";      g.pixel(x, y, k);    }  }}function plinth(g) {  g.part("plinth", () => {    taperRows(g, [[58, 6, true], [59, 6], [60, 6], [61, 6], [55, 4, true], [56, 4], [57, 4]]);    g.line([CX - 6, 61], [CX + 6, 61], "n1"); g.pixel(CX - 6, 61, "n2");    g.line([CX - 4, 57], [CX + 4, 57], "n1");    g.pixel(CX - 5, 60, "n4"); g.pixel(CX + 3, 59, "n0");                 // a lit bolt, a pit    g.pixel(CX - 5, 59, "n2");  });  g.part("foot", () => {    taperRows(g, [[50, 1], [51, 2], [52, 3], [53, 3], [54, 2]]);    g.pixel(CX - 2, 52, "n4"); g.pixel(CX - 3, 52, "n3");  });}function post(g) {  g.part("post", () => {    for (let y = 29; y <= 49; y++) { g.pixel(CX - 1, y, "n3"); g.pixel(CX, y, "n2"); g.pixel(CX + 1, y, "n1"); }    for (const y of [31, 32, 44, 45, 46]) g.pixel(CX - 1, y, "n4");      // specular streaks  });  g.part("collar", () => {    for (const [y, hw] of [[39, 2], [40, 2], [41, 1]]) for (let x = CX - hw; x <= CX + hw; x++) {      const u = (x - CX) / hw;      g.pixel(x, y, y === 39 ? (u < -0.4 ? "g3" : u < 0.4 ? "g2" : "g1") : u < -0.4 ? "g2" : u < 0.4 ? "g1" : "g0");    }  });}function brackets(g) {  g.part("brackets", () => {    // a cradle cone under the lantern, then two scrolls curling down to the post    taperRows(g, [[26, 4], [27, 3], [28, 2]]);    for (const s of [-1, 1]) {      const px = (dx, dy, k) => g.pixel(CX + s * dx, dy, k);      const hi = s < 0 ? "n3" : "n2", lo = s < 0 ? "n2" : "n1";      px(4, 27, hi); px(5, 28, hi); px(5, 29, lo); px(4, 30, lo); px(3, 30, lo); px(3, 29, s < 0 ? "n4" : "n2");      px(2, 31, lo); px(2, 32, lo);    }  });}function cap(g, litState) {  g.part("cap", () => {    // pyramid roof: three faces (left lit, front, right shaded) over a wide brim    for (let y = 6; y <= 12; y++) {      const hw = y === 12 ? 7 : y - 5;      for (let x = CX - hw; x <= CX + hw; x++) {        const u = (x - CX) / (hw + 0.5);        let k = u < -0.38 ? "n4" : u <= 0.38 ? "n2" : "n1";        if (y === 12) k = u < -0.5 ? "n3" : u < 0.5 ? "n2" : "n1";         // brim edge        if (y === 6) k = "n3";        g.pixel(x, y, k);      }    }    g.pixel(CX - 6, 12, "n4");    g.line([CX - 6, 13], [CX + 6, 13], "n0");                                // brim underside shadow    // vent holes on the front face    g.pixel(CX, 10, "n0"); g.pixel(CX, 8, "n1");  });  g.part("finial", () => {    g.pixel(CX, 1, "g1");    g.pixel(CX - 1, 2, "g2"); g.pixel(CX, 2, "g3"); g.pixel(CX + 1, 2, "g1");    g.pixel(CX - 1, 3, "g1"); g.pixel(CX, 3, "g2"); g.pixel(CX + 1, 3, "g0");    g.pixel(CX, 4, "g1"); g.pixel(CX, 5, "g0");  });}// ---- glass -----------------------------------------------------------------------------------------------// light: { lvl 0..1 (glass fill), gr glow radius, glint (unlit sweep position or null) }function frameAndGlass(g, L) {  g.part("lantern", () => g.field({ x: [CX - 7, CX + 7], y: [14, 25] }, (x, y) => {    if (y === 14 || y === 25) {                                            // top and bottom frame bands      const hw = y === 14 ? 6 : 5, u = (x - CX) / hw;      if (Math.abs(x - CX) > hw) return null;      return y === 14 ? (u < -0.5 ? "n3" : u < 0.5 ? "n2" : "n1") : (u < -0.5 ? "n2" : "n1");    }    const hw = glassHW(y), u = (x - CX) / hw;    if (Math.abs(u) > 1.02) return null;    const au = Math.abs(u);    if (au > 0.84) return u < 0 ? "n3" : "n1";                             // outer frame posts    if (au > 0.33 && au < 0.52) return L.lvl > 0.5 ? "n0" : u < 0 ? "n2" : "n1";   // mullions    const front = au <= 0.33, left = u < 0;    if (L.lvl <= 0) {      // dark glass: the left pane catches sky, the right pane is in shade; a faint fixed reflection      if (L.glint != null) {        const s = x + (y - GT) * 0.6 - L.glint;        if (Math.abs(s) < 0.8) return front ? "n4" : "n3";        if (Math.abs(s - 2) < 0.6) return front ? "n3" : "n2";      }      if (front) return Math.round(x - CX + (y - GT) * 0.5) === -1 && y < GB - 3 ? "n2" : y < GT + 2 ? "n1" : "b0";      return left ? "n2" : "n0";    }    const d = Math.hypot(x - FC.x, (y - FC.y) * 0.8);    if (L.lvl < 1) {                                                       // glass filling while it catches      if (d < L.gr) return front ? "fl1" : "fl0";      if (d < L.gr + 2) return front ? "fl0" : (left ? "n2" : "n0");      return front ? "n1" : left ? "n2" : "n0";    }    if (front) return d < L.gr ? "fl2" : "fl1";    return au < 0.66 && d < L.gr + 2 ? "fl1" : "fl0";  }));  g.part("burner", () => {    g.line([CX - 1, 23], [CX + 1, 23], L.lvl >= 1 ? "g0" : "g1");    g.line([CX - 2, 24], [CX + 2, 24], L.lvl >= 1 ? "g0" : "g2");    g.pixel(CX - 2, 24, L.lvl >= 1 ? "g1" : "g3");    g.pixel(CX, 22, "n0");                                                 // wick  });}function flameShape(g, t, s) {  // a small gas flame over the burner: a teardrop that sways and stretches (periodic in t)  if (s <= 0) return;  const hgt = s * (5 + 0.9 * Math.sin(TAU * (2 * t + 0.1)) + 0.5 * Math.sin(TAU * (3 * t + 0.6)));  g.part("flame", () => g.field({ x: [CX - 3, CX + 3], y: [12, 22] }, (x, y) => {    const k = (21.5 - y) / hgt;                                            // 0 at the base, 1 at the tip    if (k < 0 || k > 1) return null;    const ax = CX + (0.9 * Math.sin(TAU * (t + 0.2)) + 0.5 * Math.sin(TAU * (2 * t))) * k * k;    const hw = Math.min(1, s) * (k < 0.3 ? 1.2 + k : 1.5 * Math.pow((1 - k) / 0.7, 0.8));    const dx = Math.abs(x - ax);    if (dx > hw) return null;    if (k < 0.18) return "fl1";                                            // blue-ish root in real gas; warm here    return dx < hw * 0.5 && k < 0.7 ? "fl3" : "fl2";  }));}const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];function halo(g, t, amt) {  if (amt <= 0) return;  const R = (12.5 + 0.8 * Math.sin(TAU * 2 * t) + 0.4 * Math.sin(TAU * (3 * t + 0.4))) * amt;  g.detached("halo", () => g.field({ x: [0, W - 1], y: [4, 36] }, (x, y) => {    const r = Math.hypot(x - FC.x, (y - FC.y) * 0.85);    if (r > R) return null;    const dens = 0.68 * Math.pow(1 - r / R, 0.85);    if (BAYER[(y & 3) * 4 + (x & 3)] / 16 >= dens) return null;    return r < R * 0.62 ? "fl2" : "fl1";  }));}function moths(g, t) {  const M = [    { rx: 10, ry: 5, cy: 17, sp: 1, ph: 0.1 },    { rx: 9, ry: 9, cy: 21, sp: -2, ph: 0.55 },  ];  M.forEach((m, i) => {    const a = TAU * (m.sp * t + m.ph);    const x = Math.round(FC.x + Math.cos(a) * m.rx + Math.sin(TAU * (3 * t + i * 0.4)) * 0.8);    const y = Math.round(m.cy + Math.sin(a) * m.ry * 0.6 + Math.sin(TAU * (4 * t + i * 0.3)) * 1.2);    const near = Math.sin(a) > 0;    const flap = (Math.round(t * 12) + i) % 2 === 0;    g.depth(near ? -3 : 3, () => g.detached(`moth${i}`, () => {      g.pixel(x, y, "n4");      if (flap) g.pixel(x, y - 1, "n5"); else g.pixel(x + (m.sp > 0 ? -1 : 1), y, "n3");    }));  });}function spark(g, f) {  g.detached("spark", () => {    if (f === 1) {      g.pixel(CX, 21, "fl3");      for (const [ox, oy] of [[1, 0], [-1, 0], [0, -1]]) g.pixel(CX + ox, 21 + oy, "fl2");      g.pixel(CX + 2, 19, "fl1"); g.pixel(CX - 2, 18, "fl1");    } else if (f === 2) {      g.pixel(CX + 2, 17, "fl2"); g.pixel(CX - 3, 16, "fl1");    }  });}// ---- states ------------------------------------------------------------------------------------------------const LIGHT = 8;// per light frame: flame scale, glass level, glow radius, halo amountconst LS = [0, 0, 0.35, 0.6, 1.0, 1.25, 1.08, 1];const LL = [0, 0, 0.5, 0.7, 1, 1, 1, 1];const LG = [0, 0, 1.5, 2.5, 3.2, 4, 3.6, 0];const LH = [0, 0, 0, 0, 0.55, 1.1, 1.02, 1];const glowR = (t) => 3 + 0.5 * Math.sin(TAU * (2 * t + 0.1)) + 0.3 * Math.sin(TAU * (3 * t + 0.6));const Lantern = sprite(pal, (g, t, state) => {  t = ((t % 1) + 1) % 1;                                                    // hosts probe t = 1 and t < 0 too  let L, fs = 0, ft = t, hal = 0, f = -1, withMoths = false;  if (state === "lit") { L = { lvl: 1, gr: glowR(t) }; fs = 1; hal = 1; withMoths = true; }  else if (state === "unlit") {    const p = (t - 0.4) / 0.35;                                             // the glint crosses mid-loop    L = { lvl: 0, glint: p >= 0 && p <= 1 ? CX - 9 + p * 18 : null };  } else {    f = Math.min(LIGHT - 1, Math.round(t * LIGHT));    ft = ((f - (LIGHT - 1)) / 12 + 1) % 1;                                  // on the lit loop's 10 fps clock    fs = LS[f]; hal = LH[f];    L = f >= LIGHT - 1 ? { lvl: 1, gr: glowR(ft) } : f >= 4 ? { lvl: 1, gr: LG[f] } : { lvl: LL[f], gr: LG[f], glint: null };    if (f === 0) L = { lvl: 0, glint: null };    withMoths = f >= LIGHT - 1;  }  contactShadow(g, CX, GROUND, 7, 2);  g.outlined("ink", () => {    cap(g);    frameAndGlass(g, L);    brackets(g);    post(g);    plinth(g);  });  flameShape(g, ft, fs);  if (f >= 1 && f <= 2) spark(g, f);  g.depth(2, () => halo(g, ft, hal));  if (withMoths) moths(g, ft);});submit(Lantern, {  size: [W, H], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: 1.2, loop: true },  anchor: { x: CX, y: GROUND },  args: ["lit"],  variants: [    { name: "lit" },    { name: "unlit", args: ["unlit"], animate: { fps: 10, duration: 1.6, loop: true } },    { name: "light", args: ["light"], animate: { fps: 10, duration: 0.8, loop: false } },    { name: "lit-soul", repalette: swap("fl", "soul") },    { name: "light-soul", args: ["light"], animate: { fps: 10, duration: 0.8, loop: false }, repalette: swap("fl", "soul") },  ],});

掲載されたコードでは、ビルドスクリプトが自動付与する共通部分を省略しています。具体的には 1 行のランタイムヘッダー、共有パレット、および汎用ヘルパー関数(contactShadow、litIndex、flame、hash、chk、TAU)です。表示されている行番号は実際のファイルと一致しています。これらのプログラムは AIエージェントが生成し、描画されたコマの仕上がりを確認しながら修正を加えたものです。

エージェントに、こうしたプログラムを書かせる。

Tessarune は描画 API のリファレンス、25 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。