Tavern & Market · street_lantern.js
街灯
背の高い 32×64 の床置きの小物で、見下ろし 3/4 視点です。段のある台座、真鍮の輪、渦巻きの腕木を持つ鋳鉄の街灯柱に、真鍮の飾りを載せた角錐の笠と六角形のガラスのランタンがあり、中の真鍮の火口でガスの炎が燃えます。lit(点灯、12 コマのループ)は炎が揺れ、ガラスも一緒に明るさが変わり、点描の光の輪が脈打ち、蛾が 2 匹回ります。unlit(消灯、16 コマのループ)は暗いガラスを 1 ループに一度かすかな光が横切ります。light(点火、8 コマの一回再生)は火口に火花が散り、炎が付いて燃え上がります。炎の色を一式差し替えると、青い魂火の lit-soul と light-soul になります。
6×
動くスプライト素材: 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 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。