Tavern & Market · notice_board.js
掲示板
48×48 の床置きの小物で、3/4 視点です。2 本の角材の柱が、小さなスレート屋根の下の板を支えています。人の顔の影絵の WANTED の手配書、赤い蝋で封をした手紙、角のはがれた古い紙、斜めの小さな紙、破れた切れ端が留めてあります。読める文字はなく、行はすべて破線の模様です。idle(待機、12 コマのループ)は古い紙の角が風に揺れます。post(8 コマの一回再生)は小さな宝の地図が飛んできて、たたきつけられ、ピンで留められます。idle_full(12 コマのループ)は地図を留めた後の待機です。
6×
動くスプライト素材: 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 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。