Tessarune
Tavern & Market · notice_board.js

掲示板

48×48 · JavaScript 289 行

48×48 の床置きの小物で、3/4 視点です。2 本の角材の柱が、小さなスレート屋根の下の板を支えています。人の顔の影絵の WANTED の手配書、赤い蝋で封をした手紙、角のはがれた古い紙、斜めの小さな紙、破れた切れ端が留めてあります。読める文字はなく、行はすべて破線の模様です。idle(待機、12 コマのループ)は古い紙の角が風に揺れます。post(8 コマの一回再生)は小さな宝の地図が飛んできて、たたきつけられ、ピンで留められます。idle_full(12 コマのループ)は地図を留めた後の待機です。

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

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

notice_board.js · 1–8
// Notice board — 48x48 floor prop, 3/4 view, key light top-left.// Two squared wooden posts carry a plank board under a small slate-tiled roof. Pinned to it: a WANTED poster with a// face silhouette, a sealed letter (red wax), an old note whose loose corner lifts in the wind, a small slanted// note, and a torn scrap left by an older notice. No readable text: every "line" is a dash pattern.// Variants://   idle       12f loop — the old note's corner flutters in gusts//   post       8f one-shot — a new note (a little treasure map) sails in, is slapped flat and pinned; ends == idle_full f0//   idle_full  12f loop — idle with the new note pinned

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

notice_board.js · 279–289
submit(NoticeBoard, {  size: [W, H], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: N / 10, loop: true },  anchor: { x: CX, y: GROUND },  args: ["idle"],  variants: [    { name: "idle" },    { name: "post", args: ["post"], animate: { fps: 10, duration: POST.length / 10, loop: false } },    { name: "idle_full", args: ["idle_full"] },  ],});
プログラム全体を表示 (289 行)
notice_board.js · 289 行
// Notice board — 48x48 floor prop, 3/4 view, key light top-left.// Two squared wooden posts carry a plank board under a small slate-tiled roof. Pinned to it: a WANTED poster with a// face silhouette, a sealed letter (red wax), an old note whose loose corner lifts in the wind, a small slanted// note, and a torn scrap left by an older notice. No readable text: every "line" is a dash pattern.// Variants://   idle       12f loop — the old note's corner flutters in gusts//   post       8f one-shot — a new note (a little treasure map) sails in, is slapped flat and pinned; ends == idle_full f0//   idle_full  12f loop — idle with the new note pinnedconst W = 48, H = 48, CX = 24, GROUND = 45;const POSTS = [5, 40];                       // left x of each 3px postconst FRAME = { x0: 8, x1: 39, y0: 13, y1: 35 };const PANEL = { x0: 10, x1: 37, y0: 15, y1: 33 };// ---- structure ------------------------------------------------------------------------------------function posts(g) {  g.part("posts", () => {    for (const x of POSTS) {      g.rect({ x: [x, x + 2], y: [10, GROUND - 1] }, "w2", { fill: "w2" });      g.line([x, 10], [x, GROUND - 1], "w3");      g.line([x + 2, 10], [x + 2, GROUND - 1], "w1");      g.line([x, GROUND - 1], [x + 2, GROUND - 1], "w0");              // the foot sinks into the ground      g.pixel(x + 1, 39, "w1"); g.pixel(x + 1, 40, "w1");              // a grain check    }    g.pixel(POSTS[0] + 1, 42, "w4"); g.pixel(POSTS[1] + 1, 37, "w3");  });  g.part("rail", () => {                                               // a cross rail braces the legs    g.line([POSTS[0] + 3, 41], [POSTS[1] - 1, 41], "w3");    g.line([POSTS[0] + 3, 42], [POSTS[1] - 1, 42], "w1");    g.line([POSTS[1] - 6, 41], [POSTS[1] - 1, 41], "w2");    g.pixel(POSTS[0] + 3, 42, "w2");  });}function roof(g) {  const edge = (y) => Math.round(5 - ((y - 3) * 3) / 7);             // left end of each row: flares toward the eave  g.part("roof", () => g.field({ x: [1, W - 2], y: [3, 10] }, (x, y) => {    const l = edge(y), r = W - 1 - l;    if (x < l || x > r) return null;    const u = (x - l) / (r - l);                                         // 0 left .. 1 right    if (y === 3) return u < 0.35 ? "n4" : u < 0.8 ? "n3" : "n2";         // ridge cap    const c = (y - 4) >> 1, top = ((y - 4) & 1) === 0;                   // courses of slates, 2 rows each    const joint = ((x + c * 2) % 5) === 0;    if (x === l) return "n3";    if (x === r) return "n1";    const nearJ = (((x + c * 2) % 5) + 5) % 5;                          // 0 = joint, 1..4 across one slate    if (!top) {                                                          // rounded slate bottoms: dark corners, lit belly      if (joint) return "n0";      if (nearJ === 1 || nearJ === 4) return "n1";      return u < 0.3 ? "n3" : u < 0.8 ? "n2" : "n1";    }    if (joint) return u < 0.3 ? "n2" : "n1";    return u < 0.3 ? "n3" : u < 0.8 ? "n2" : "n1";  }));  g.part("fascia", () => {    g.line([2, 11], [W - 3, 11], "w2");    g.line([2, 11], [12, 11], "w3");    g.line([W - 8, 11], [W - 3, 11], "w1");    g.pixel(2, 11, "w4");  });}function boardFrame(g) {  const { x0, x1, y0, y1 } = FRAME;  g.part("frame", () => g.field({ x: [x0, x1], y: [y0, y1] }, (x, y) => {    const inPanel = x >= PANEL.x0 && x <= PANEL.x1 && y >= PANEL.y0 && y <= PANEL.y1;    if (inPanel) {      // vertical planks, dark so the paper pops; the frame and roof shade the top edge      if (y === PANEL.y0) return "w0";      if (x === PANEL.x0) return "w0";      if ((x - PANEL.x0) % 7 === 0) return "w0";                        // plank seams      if (y === PANEL.y0 + 1 && chk(x, y)) return "w0";                 // roof shadow dithers out      return (x - PANEL.x0) % 7 === 1 ? "w2" : "w1";                    // lit left edge of each plank    }    if (y === y0) return x < x1 - 4 ? "w4" : "w3";                      // top edge face    if (x === x0 || y === y0 + 1 && x < x1) return "w3";    if (x === x1 || y === y1) return "w1";    if (x === x1 - 1 && y > y0) return "w1";    if (y === y1 - 1) return x < x0 + 3 ? "w2" : "w1";    return "w2";  }));  g.part("grain", () => {    for (const [x, y] of [[14, 14], [15, 14], [27, 34], [9, 22], [9, 23]]) g.pixel(x, y, "w1");    for (const [x, y] of [[13, 21], [20, 31], [33, 19]]) g.pixel(x, y, "w2");   // knots / old pin holes lit    for (const [x, y] of [[25, 17], [34, 30], [16, 32]]) g.pixel(x, y, "w0");  });}// ---- paper ----------------------------------------------------------------------------------------// one char per pixel. P aged paper, Q fresh paper, p/q their shaded edge, K dark ink, k faint ink,// F face-box ground, S silhouette, r/R/o wax (body, highlight, shade), e green ink, X red ink, . emptyconst PKEY = { P: "n4", Q: "n5", p: "n3", q: "n4", K: "n1", k: "n2", l: "n3", F: "n3", S: "n1", r: "r1", R: "r2", o: "r0", e: "e1", X: "r1" };const WANTED = { x: 11, y: 15, pin: [15, 15, "red"], rows: [  "PPPPPPPPp",  "PPPPPPPPp",  "PKKPKPKKp",  "PKKPKPKKp",  "PPPPPPPPp",  "PFFFFFFPp",  "PFFSSFFPp",  "PFSSSSFPp",  "PFFSSFFPp",  "PFSSSSFPp",  "PSSSSSSPp",  "PPPPPPPPp",  "PkkkPkkPp",  "PkkPkkkPp",  ".pppppppp",] };const LETTER = { x: 21, y: 16, pin: [22, 16, "brass"], rows: [  "QQQQQQQQq",  "QllllllQq",  "QQQQQQQQq",  "QlllllQQq",  "QQQQQQQQq",  "QQQrrrQQq",  "QQrRrroQq",  "QQQroQQQq",  "qqqqoqqqq",] };const SLANT = { x: 31, y: 16, pin: [33, 16, "red"], shear: [[0, 3, 1]], rows: [  "QQQQQq",  "QlllQq",  "QQQQQq",  "QllQQq",  "QQQQQq",  "QlllQq",  "qqqqqq",] };const OLD = { x: 21, y: 26, pin: [22, 26, "brass"], rows: [  "PPPPPPPp",  "PkkkkkPp",  "PPPPPPPp",  "PkkkPPPp",  "PPPPPPPp",  "PkkkkPPp",  "pppppppp",] };const MAP = { x: 30, y: 25, pin: [33, 25, "red"], rows: [  "PPPPPPp",  "PekPPPp",  "PPPkPPp",  "PPXPXPp",  "PPPXPPp",  "PPXPXPp",  "ppppppp",] };const SCRAP = { x: 11, y: 31, pin: [12, 31, "old"], rows: ["pP.", "p.."] };const noteW = (n) => n.rows[0].length, noteH = (n) => n.rows.length;function noteKey(n, u, v, lift) {  const r = n.rows[v], c = r && r[u];  if (!c || c === ".") return null;  if (lift) {    // corner flutter: the bottom-right triangle folds up toward the viewer, showing the paper's back    const w = noteW(n), h = noteH(n), d = (w - 1 - u) + (h - 1 - v);    if (d < lift) return "gone";    if (d < 2 * lift) return d === 2 * lift - 1 ? "edge" : "back";      // the flap; its far edge darker  }  return c;}function shiftOf(n, v) {  if (!n.shear) return 0;  for (const [a, b, s] of n.shear) if (v >= a && v <= b) return s;  return 0;}function pin(g, x, y, kind) {  if (kind === "red") { g.pixel(x, y, "r2"); g.pixel(x + 1, y + 1, "r0"); }  else if (kind === "brass") { g.pixel(x, y, "g2"); g.pixel(x + 1, y + 1, "g0"); }  else g.pixel(x, y, "w0");}function note(g, name, n, lift) {  const w = noteW(n), h = noteH(n);  g.part(name, () => {    // drop shadow on the planks (right and below), then the sheet    g.field({ x: [n.x, n.x + w + 1], y: [n.y, n.y + h] }, (x, y) => {      const u = x - n.x - shiftOf(n, y - n.y), v = y - n.y;      const k = noteKey(n, u, v, lift);      if (k === "gone") return "w0";      if (k === "back") return "n3";      if (k === "edge") return "n2";      if (k) return PKEY[k];      // shadow: one pixel right / below of the sheet      const ks = noteKey(n, u - 1, v - 1, lift);      return ks && ks !== "gone" ? "w0" : null;    });    if (n.pin) pin(g, n.pin[0], n.pin[1], n.pin[2]);  });}// ---- the new note in flight (post) ----------------------------------------------------------------// drawn by inverse-mapping each pixel into the sheet's frame, so it can arrive tiltedfunction flyingNote(g, dx, dy, deg, lifted) {  const n = MAP, w = noteW(n), h = noteH(n);  const cx = n.x + w / 2 + dx, cy = n.y + h / 2 + dy;  const a = (deg * Math.PI) / 180, ca = Math.cos(a), sa = Math.sin(a);  const sample = (x, y) => {    const px = x + 0.5 - cx, py = y + 0.5 - cy;    const u = Math.floor(px * ca + py * sa + w / 2), v = Math.floor(-px * sa + py * ca + h / 2);    if (u < 0 || v < 0 || u >= w || v >= h) return null;    return n.rows[v][u];  };  const onPanel = (x, y) => x >= PANEL.x0 && x <= PANEL.x1 && y >= PANEL.y0 && y <= PANEL.y1;  const R = 9;  const bx = [Math.floor(cx - R), Math.ceil(cx + R)], by = [Math.floor(cy - R), Math.ceil(cy + R)];  if (lifted) g.part("newShadow", () => g.field({ x: bx.map((v) => v + lifted), y: by.map((v) => v + lifted) }, (x, y) =>    onPanel(x, y) && sample(x - lifted, y - lifted) ? "w0" : null));  g.part("newNote", () => g.field({ x: bx, y: by }, (x, y) => {    const c = sample(x, y);    if (c) return PKEY[c];    // a 1px ink rim where the sheet flies over open air, so it reads against any floor    if (!onPanel(x, y) && (sample(x - 1, y) || sample(x + 1, y) || sample(x, y - 1) || sample(x, y + 1))) return "ink";    return null;  }));}function trail(g, dx, dy, len) {  // speed lines streaming back toward where the sheet came from (up and to the right)  const cx = MAP.x + noteW(MAP) / 2 + dx, cy = MAP.y + noteH(MAP) / 2 + dy;  g.detached("trail", () => {    for (const [ox, oy, l] of [[-1, -2, len], [2, 1, len - 1]]) {      for (let i = 0; i < l; i++) {        const r = 5 + i;        const x = Math.round(cx + ox + 0.8 * r), y = Math.round(cy + oy - 0.6 * r);        if (x >= 0 && x < W && y >= 0) g.pixel(x, y, i === 0 ? "n5" : i < 3 ? "n4" : "n3");      }    }  });}function flash(g, x, y, size) {  // impact: four short ticks burst out around the pin  g.detached("flash", () => {    const r = size > 1 ? 2 : 3;    for (const [sx, sy] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) {      g.pixel(x + sx * r, y + sy * r, "n5");      if (size > 1) g.pixel(x + sx * (r + 1), y + sy * (r + 1), "g3");    }    if (size > 1) g.pixel(x, y - 3, "n5");  });}// ---- motion ---------------------------------------------------------------------------------------const N = 12;const FLUTTER = [0, 0, 0, 1, 2, 3, 2, 3, 2, 1, 0, 0];                   // corner lift per idle frame// post: [dx, dy, degrees, lifted shadow offset, corner bounce, pin shown, flash size]const POST = [  [9, -9, 34, 2, 0, false, 0],  [8, -5, 20, 2, 0, false, 0],  [3, -2, 8, 2, 0, false, 0],  [1, 0, 2, 1, 0, false, 0],  [0, 0, 0, 0, 0, true, 2],  [0, 0, 0, 0, 2, true, 1],  [0, 0, 0, 0, 1, true, 0],  [0, 0, 0, 0, 0, true, 0],];const NoticeBoard = sprite(pal, (g, t, state) => {  contactShadow(g, CX, GROUND, 20, 2);  g.outlined("ink", () => { posts(g); boardFrame(g); roof(g); });  let lift = 0;  if (state !== "post") lift = FLUTTER[Math.round(t * N) % N];  note(g, "scrap", SCRAP, 0);  note(g, "wanted", WANTED, 0);  note(g, "letter", LETTER, 0);  note(g, "slanted", SLANT, 0);  note(g, "oldNote", OLD, lift);  if (state === "idle_full") note(g, "mapNote", MAP, 0);  if (state === "post") {    const f = Math.min(POST.length - 1, Math.round(t * POST.length));    const [dx, dy, deg, lifted, bounce, pinned, fl] = POST[f];    if (deg === 0 && dx === 0 && dy === 0) {      note(g, "mapNote", Object.assign({}, MAP, { pin: pinned ? MAP.pin : null }), bounce);    } else {      flyingNote(g, dx, dy, deg, lifted);      if (f < 3) trail(g, dx, dy, 5 - f);    }    if (fl) flash(g, MAP.pin[0], MAP.pin[1], fl);  }});submit(NoticeBoard, {  size: [W, H], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: N / 10, loop: true },  anchor: { x: CX, y: GROUND },  args: ["idle"],  variants: [    { name: "idle" },    { name: "post", args: ["post"], animate: { fps: 10, duration: POST.length / 10, loop: false } },    { name: "idle_full", args: ["idle_full"] },  ],});

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

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

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