@gtanczyk/diorama 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/app/src/actions/budget.ts +11 -3
- package/app/src/actions/cast.ts +19 -0
- package/app/src/actions/fix.ts +28 -10
- package/app/src/actions/index.ts +1 -0
- package/app/src/actions/language.ts +52 -0
- package/app/src/actions/world.ts +4 -3
- package/app/src/commands/backdrop.ts +23 -3
- package/app/src/commands/cast.ts +32 -13
- package/app/src/commands/export.ts +17 -0
- package/app/src/commands/index.ts +1 -0
- package/app/src/commands/language.ts +87 -0
- package/app/src/commands/play.ts +5 -1
- package/app/src/commands/sound.ts +16 -6
- package/app/src/meta/memo.ts +14 -0
- package/app/src/meta/voice.ts +2 -2
- package/app/src/play/geometry.ts +12 -2
- package/app/src/play/parallax.ts +2 -0
- package/app/src/scene/cast-fixes.ts +5 -3
- package/app/src/scene/cast.ts +16 -9
- package/app/src/scene/derive.ts +46 -1
- package/app/src/scene/held.ts +62 -8
- package/app/src/scene/layers.ts +52 -0
- package/app/src/scene/rig.ts +6 -2
- package/app/src/scene/shadows.ts +10 -2
- package/app/src/scene/verify.ts +12 -0
- package/app/src/scene/warp.ts +5 -3
- package/app/src/setup/checks.ts +2 -2
- package/app/src/speech/transcribe.ts +2 -2
- package/app/src/studio/agent.ts +10 -8
- package/app/src/studio/repair.ts +1 -1
- package/app/src/studio/server.ts +4 -2
- package/app/src/world/language.ts +116 -0
- package/app/studio/build.tsx +5 -3
- package/app/studio/main.tsx +3 -2
- package/app/web/living.ts +36 -0
- package/app/web/main.ts +83 -30
- package/docs/characters.md +1 -1
- package/docs/releasing.md +22 -0
- package/docs/scenes.md +1 -1
- package/docs/setup.md +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// A world told in another language: every word the player reads or hears (title, pitch, names, the whole story)
|
|
2
|
+
// translated in one Spark pass, so names stay the same everywhere they appear. What the painters read (style,
|
|
3
|
+
// shows, mood, looks, a change's looks) is English already and stays; keys and flags never change, so the
|
|
4
|
+
// puzzles, masks, paintings and sounds all still fit. The voices are cast again for the new language afterwards.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { askSparkJson } from '../meta/spark.ts';
|
|
8
|
+
import type { Memo } from '../meta/memo.ts';
|
|
9
|
+
import { soundQuality } from '../sound/cost.ts';
|
|
10
|
+
import { languageKey } from '../sound/language.ts';
|
|
11
|
+
import { needsDesign, storyLines, Voices } from '../sound/voices.ts';
|
|
12
|
+
import type { Story } from '../story/schema.ts';
|
|
13
|
+
import type { WorldFiles } from './files.ts';
|
|
14
|
+
import type { World } from './schema.ts';
|
|
15
|
+
|
|
16
|
+
/** Story fields the player reads or hears. A change's `looks` is for the painter: it stays English. */
|
|
17
|
+
const STORY_TEXT = new Set(['intro', 'say', 'says', 'text', 'title', 'name', 'nope']);
|
|
18
|
+
|
|
19
|
+
/** Every player-facing text of a world and its story, by an id that says where it lives. */
|
|
20
|
+
export function worldTexts(world: World, story: Story | null): Record<string, string> {
|
|
21
|
+
const out: Record<string, string> = { 'world.title': world.title, 'world.pitch': world.pitch };
|
|
22
|
+
for (const [key, name] of Object.entries(world.names ?? {})) out[`names.${key}`] = name;
|
|
23
|
+
const walk = (node: unknown, path: string[]) => {
|
|
24
|
+
if (Array.isArray(node)) node.forEach((n, i) => walk(n, [...path, String(i)]));
|
|
25
|
+
else if (node && typeof node === 'object') {
|
|
26
|
+
for (const [k, v] of Object.entries(node)) {
|
|
27
|
+
if (typeof v === 'string') {
|
|
28
|
+
if (STORY_TEXT.has(k) && !(path[0] === 'changes')) out[`story.${[...path, k].join('.')}`] = v;
|
|
29
|
+
} else walk(v, [...path, k]);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
if (story) walk(story, []);
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The world and story with the translated texts put back at their ids. */
|
|
38
|
+
export function applyWorldTexts(world: World, story: Story | null, texts: Record<string, string>, language: string): { world: World; story: Story | null } {
|
|
39
|
+
const w: World = structuredClone(world);
|
|
40
|
+
const s: Story | null = story && structuredClone(story);
|
|
41
|
+
for (const [id, text] of Object.entries(texts)) {
|
|
42
|
+
const [head, ...rest] = id.split('.');
|
|
43
|
+
if (head === 'world') (w as Record<string, unknown>)[rest[0]] = text;
|
|
44
|
+
else if (head === 'names') w.names = { ...w.names, [rest.join('.')]: text };
|
|
45
|
+
else if (head === 'story' && s) {
|
|
46
|
+
let at: unknown = s;
|
|
47
|
+
const last = rest.pop()!;
|
|
48
|
+
for (const p of rest) at = Array.isArray(at) ? at[Number(p)] : (at as Record<string, unknown>)[p];
|
|
49
|
+
if (at && typeof at === 'object') (at as Record<string, unknown>)[last] = text;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (languageKey(language) === 'en') delete w.language;
|
|
53
|
+
else w.language = language;
|
|
54
|
+
return { world: w, story: s };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const Answer = z.object({ texts: z.record(z.string(), z.string()) });
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* One Spark pass over all the texts, so a name reads the same in the title, the dialogue and the inventory.
|
|
61
|
+
* Ids missing from the answer are asked for once more, with the names already chosen; an empty answer throws.
|
|
62
|
+
*/
|
|
63
|
+
export async function translateTexts(memo: Memo, texts: Record<string, string>, from: string, to: string, kids: boolean): Promise<Record<string, string>> {
|
|
64
|
+
const ask = (todo: Record<string, string>, names: Record<string, string>) => askSparkJson(memo, {
|
|
65
|
+
label: `language:${languageKey(to)}`,
|
|
66
|
+
schema: Answer,
|
|
67
|
+
prompt: `Translate the player-facing text of a point-and-click adventure game from ${from} to ${to}.
|
|
68
|
+
Write it as a native ${to} game writer would${kids ? ' for children aged 5 to 7, who hear every line read aloud' : ''}: natural, idiomatic and warm, not word for word. Keep each line's meaning, mood and humour, and keep it about as short (never longer by more than a few words).
|
|
69
|
+
- Names of characters, places and items ("names.*", items' "name"): give each a natural ${to} name that keeps the idea or pun of the original (a cat named after butter keeps a buttery name). Use exactly the same name every time it appears in any line, with the same capitalisation style as the original (a lowercase item name stays lowercase).
|
|
70
|
+
- A line that is a statement stays a statement; a question stays a question. Keep sound tags in square brackets ([laughs], [whispers]) exactly as they are, untranslated.
|
|
71
|
+
- Numbers, riddles and wordplay must still work in ${to}: adapt them, keep the answer the same.
|
|
72
|
+
- Every text must be fully in ${to}: no ${from} words left, except a name that is the same in both languages.
|
|
73
|
+
${Object.keys(names).length ? `Names already chosen (use them): ${JSON.stringify(names)}\n` : ''}Texts (id: text): ${JSON.stringify(todo)}
|
|
74
|
+
Reply with JSON only: {"texts": {"<the same id>": "<${to} text>", ...}} with every id above exactly once.`,
|
|
75
|
+
});
|
|
76
|
+
const first = (await ask(texts, {})).texts;
|
|
77
|
+
const done = Object.fromEntries(Object.keys(texts).filter((id) => first[id]?.trim()).map((id) => [id, first[id].trim()]));
|
|
78
|
+
const missing = Object.fromEntries(Object.entries(texts).filter(([id]) => !done[id]));
|
|
79
|
+
if (Object.keys(missing).length) {
|
|
80
|
+
const names = Object.fromEntries(Object.entries(done).filter(([id]) => id.startsWith('names.') || /\.name$/.test(id)));
|
|
81
|
+
const again = (await ask(missing, names)).texts;
|
|
82
|
+
for (const id of Object.keys(missing)) if (again[id]?.trim()) done[id] = again[id].trim();
|
|
83
|
+
}
|
|
84
|
+
const still = Object.keys(texts).filter((id) => !done[id]);
|
|
85
|
+
if (still.length) throw new Error(`Spark left ${still.length} text(s) untranslated: ${still.slice(0, 5).join(', ')}`);
|
|
86
|
+
return done;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A person's voice wish written for the old language ("Native Polish speaker…") says the new one instead. */
|
|
90
|
+
export function relanguage(text: string, from: string, to: string): string {
|
|
91
|
+
return text.replace(new RegExp(`\\b${from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'gi'), to);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type LanguagePlan = { from: string; to: string; same: boolean; texts: number; lines: number; characters: number; credits: number; designs: string[]; quality: 'draft' | 'final' };
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* What a change of language redoes: one Spark pass over the texts, then every spoken line read again (about one
|
|
98
|
+
* ElevenLabs credit a character in final quality, half in draft) and a Voice Design for each child, teen or old speaker.
|
|
99
|
+
* The lines are counted in the old language: a translation is about as long.
|
|
100
|
+
*/
|
|
101
|
+
export function languagePlan(files: WorldFiles, world: World, story: Story | null, language: string): LanguagePlan {
|
|
102
|
+
const from = world.language ?? 'English';
|
|
103
|
+
const same = languageKey(from) === languageKey(language);
|
|
104
|
+
const kids = world.audience === 'kids';
|
|
105
|
+
const lines = story ? storyLines(world, story, kids) : [];
|
|
106
|
+
const characters = lines.reduce((n, l) => n + l.text.length, 0);
|
|
107
|
+
const quality = soundQuality();
|
|
108
|
+
const voices = fs.existsSync(files.path('voices.json')) ? Voices.safeParse(files.readJson(files.path('voices.json'))) : undefined;
|
|
109
|
+
const designs = voices?.success ? Object.entries(voices.data.cast).filter(([, c]) => c.profile && needsDesign(c.profile)).map(([k]) => k) : [];
|
|
110
|
+
return { from, to: language, same, texts: Object.keys(worldTexts(world, story)).length, lines: lines.length, characters, credits: Math.round(characters * (quality === 'final' ? 1 : 0.5)), designs, quality };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const planLine = (p: LanguagePlan) => (p.same
|
|
114
|
+
? `already in ${p.to}: the texts stay; the voices are read again only where they are not in ${p.to} yet`
|
|
115
|
+
: `${p.texts} texts translated from ${p.from} to ${p.to} in one Spark pass (cents); then ${p.lines} spoken lines read again, about ${p.characters} characters, ~${p.credits} ElevenLabs credits (${p.quality})`
|
|
116
|
+
+ `${p.designs.length ? `, plus a new designed voice for ${p.designs.join(', ')}` : ''}; paintings, masks, sounds and puzzles stay`);
|
package/app/studio/build.tsx
CHANGED
|
@@ -34,11 +34,12 @@ export const PHASES = [
|
|
|
34
34
|
|
|
35
35
|
/** memo/calls.jsonl's model names as people know them. */
|
|
36
36
|
export const MODEL_NAME: Record<string, string> = { spark: 'Muse Spark', muse: 'Muse Image', sam: 'SAM 3.1', sound: 'ElevenLabs', music: 'ElevenLabs', voice: 'ElevenLabs', space: '3D' };
|
|
37
|
-
const META = new Set(['Muse Spark', 'Muse Image', 'SAM 3.1']);
|
|
37
|
+
const META = new Set(['Muse Spark', 'Muse Image', 'SAM 3.1', 'Muse Voice']);
|
|
38
38
|
const tone = (by: string) => ({ 'Muse Spark': 'spark', 'Muse Image': 'image', 'SAM 3.1': 'sam' })[by] ?? 'other';
|
|
39
39
|
|
|
40
40
|
/** A model another than Meta's, by a short name: what a call's `via` or Setup's pick names. */
|
|
41
41
|
export function viaName(via: string): string {
|
|
42
|
+
if (via === 'muse-voice') return 'Muse Voice';
|
|
42
43
|
if (/claude/i.test(via)) return 'Claude';
|
|
43
44
|
if (/banana/i.test(via)) return 'Nano Banana';
|
|
44
45
|
if (/gemini|google/i.test(via)) return 'Gemini';
|
|
@@ -218,9 +219,10 @@ export function Pipeline({ job, all, live, build, typical, total, onOpen }: { jo
|
|
|
218
219
|
<section class={`pipeline${job ? ' on' : ''}`} aria-live="polite">
|
|
219
220
|
<div class="row head">
|
|
220
221
|
<strong>{job ? `${job.by === 'agent' ? 'Agent: ' : ''}${job.label}` : 'Build'}</strong>
|
|
221
|
-
{/* This job's calls,
|
|
222
|
+
{/* This job's calls, with the world's total beside it only when earlier jobs added to it: "136 of 136" read as
|
|
223
|
+
a progress bar that was always full, while it only said this job made every call the world has. */}
|
|
222
224
|
{job && calls.length > 0 && <span class="detail calls-kicker" title="Model calls this job made. The number after 'of' is the whole world so far, every build and agent job together">this job</span>}
|
|
223
|
-
{job && calls.map(([by, n]) => <span class={`count ${tone(by)}${fresh && live.lastCall?.by === by ? ' hot' : ''}`} title={`${nowBy(by)}: ${n} call${n === 1 ? '' : 's'} in this job${total?.[by] ? `, ${
|
|
225
|
+
{job && calls.map(([by, n]) => <span class={`count ${tone(by)}${fresh && live.lastCall?.by === by ? ' hot' : ''}`} title={`${nowBy(by)}: ${n} call${n === 1 ? '' : 's'} sent in this job (not a target: it grows with every take and check)${(total?.[by] ?? 0) > n ? `, ${total![by]} for this world so far` : ''}`}><ModelTag by={by} /> {n}{(total?.[by] ?? 0) > n ? <span class="of"> · {total![by]} in all</span> : null}</span>)}
|
|
224
226
|
{/* A step's raw line is cut to what it is doing ("barn-interior layout try 1"); the whole line is on hover. */}
|
|
225
227
|
<span class="detail tail" title={job ? live.tail : undefined}>{job ? (fresh && live.lastCall ? `${nowBy(live.lastCall.by)}: ${live.lastCall.label}` : live.tail.split(/: |[.!?]\s/)[0].slice(0, 60)) : ''}</span>
|
|
226
228
|
{when && <span class="detail when">{when}</span>}
|
package/app/studio/main.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Diorama Studio: Start (worlds and a new idea), Setup (what this machine needs) and the Workshop (a world
|
|
2
2
|
// building live). Everything here goes through actions, the same ones `diorama action` and the agent use.
|
|
3
3
|
import { render, type ComponentChildren } from 'preact';
|
|
4
|
-
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
4
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
|
|
5
5
|
import type { ChatNotice, SessionTurn } from 'genaicode/react';
|
|
6
6
|
import { agentDefault, askAgent, call, type AgentDefaults, fileUrl, follow, listActions, openSession, token, type StudioEvent } from './api.ts';
|
|
7
7
|
import { AgentPanel } from './agent.tsx';
|
|
@@ -592,7 +592,8 @@ function Workshop({ slug }: { slug: string }) {
|
|
|
592
592
|
const creatorBrief = useCreatorBrief(slug);
|
|
593
593
|
const worldRef = useRef<HTMLElement>(null);
|
|
594
594
|
const stageRef = useRef<HTMLElement>(null);
|
|
595
|
-
|
|
595
|
+
// Layout effect: Esc must close the drawer from the moment it shows, not only after the next frame.
|
|
596
|
+
useLayoutEffect(() => {
|
|
596
597
|
if (!byHand) return;
|
|
597
598
|
const esc = (e: KeyboardEvent) => e.key === 'Escape' && setByHand(null);
|
|
598
599
|
addEventListener('keydown', esc);
|
package/app/web/living.ts
CHANGED
|
@@ -402,3 +402,39 @@ export class Clouds {
|
|
|
402
402
|
ctx.drawImage(canvas, this.box[0] + sx, this.box[1] + sy);
|
|
403
403
|
}
|
|
404
404
|
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Small things painted falling (code rain, snow, petals): the glyphs cut out of the backdrop fall in columns,
|
|
408
|
+
* each column at its own pace, wrapping round inside their painted region (a soft mask) so they keep falling.
|
|
409
|
+
*/
|
|
410
|
+
export class Stream {
|
|
411
|
+
private readonly canvas: HTMLCanvasElement;
|
|
412
|
+
/** Column width, px: about one glyph, so a letter falls whole. */
|
|
413
|
+
private static readonly COLUMN = 22;
|
|
414
|
+
|
|
415
|
+
constructor(readonly img: HTMLImageElement, readonly region: HTMLImageElement, readonly box: Box, readonly k: number) {
|
|
416
|
+
this.canvas = canvasOf(box[2] - box[0], box[3] - box[1]);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
draw(ctx: CanvasRenderingContext2D, t: number, shift: Shift) {
|
|
420
|
+
const { img, region, canvas } = this;
|
|
421
|
+
const [w, h] = [canvas.width, canvas.height];
|
|
422
|
+
const c = canvas.getContext('2d')!;
|
|
423
|
+
c.globalCompositeOperation = 'source-over';
|
|
424
|
+
c.clearRect(0, 0, w, h);
|
|
425
|
+
const cw = Stream.COLUMN;
|
|
426
|
+
for (let x = 0, i = 0; x < w; x += cw, i++) {
|
|
427
|
+
const speed = 28 + 46 * rand(i, 31 + this.k);
|
|
428
|
+
const y = (t * speed + rand(i, 32 + this.k) * h) % h;
|
|
429
|
+
const sw = Math.min(cw, w - x);
|
|
430
|
+
c.drawImage(img, x, 0, sw, h, x, y, sw, h);
|
|
431
|
+
c.drawImage(img, x, 0, sw, h, x, y - h, sw, h);
|
|
432
|
+
}
|
|
433
|
+
// Only inside the painted rain, fading at its edges.
|
|
434
|
+
c.globalCompositeOperation = 'destination-in';
|
|
435
|
+
c.drawImage(region, 0, 0, w, h);
|
|
436
|
+
c.globalCompositeOperation = 'source-over';
|
|
437
|
+
const [sx, sy] = shift(this.box[3]);
|
|
438
|
+
ctx.drawImage(canvas, this.box[0] + sx, this.box[1] + sy);
|
|
439
|
+
}
|
|
440
|
+
}
|
package/app/web/main.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { spokenLine, type SoundData } from '../src/play/sound.ts';
|
|
|
20
20
|
import type { Figure3D, ModelInfo } from './hero3d.ts';
|
|
21
21
|
import type { Weather } from '../src/play/weather.ts';
|
|
22
22
|
import { WeatherFx } from './weather.ts';
|
|
23
|
-
import { Clouds, Critter, drawSwell, Fall, Surf } from './living.ts';
|
|
23
|
+
import { Clouds, Critter, drawSwell, Fall, Stream, Surf } from './living.ts';
|
|
24
24
|
import { stormOf } from '../src/play/living.ts';
|
|
25
25
|
import type { Reactions } from '../src/play/reactions.ts';
|
|
26
26
|
import { ReactionFx } from './reactions.ts';
|
|
@@ -35,7 +35,7 @@ type Data = {
|
|
|
35
35
|
world: World;
|
|
36
36
|
places: Record<string, { geometry: Geometry | null; painting: string | null; backdrop?: string; layers?: Layers; weather?: Weather; reactions?: Reactions; glows?: Glow[]; issue?: string }>;
|
|
37
37
|
/** Figure per hero or NPC key: a cut-out image, and its rig when `diorama cast` could cut it into parts. */
|
|
38
|
-
cast: Record<string, { image: string; height?: number; upright?: boolean; face?: { dir: string; info: FaceDefinition }; rig?: Rig; parts?: string; model?: { dir: string; info: ModelInfo }; sprites?: { dir: string; info: SpriteInfo } }>;
|
|
38
|
+
cast: Record<string, { image: string; height?: number; upright?: boolean; share?: number; face?: { dir: string; info: FaceDefinition }; rig?: Rig; parts?: string; model?: { dir: string; info: ModelInfo }; sprites?: { dir: string; info: SpriteInfo } }>;
|
|
39
39
|
story: Story | null;
|
|
40
40
|
/** Index into story.changes → the painting as it looks once that change holds. */
|
|
41
41
|
changes?: Record<string, string>;
|
|
@@ -70,7 +70,7 @@ export type SpriteInfo = {
|
|
|
70
70
|
/** walk: the cells a walk steps through, without the ones the sprites step found off the gait (a crouch, a sit). */
|
|
71
71
|
type Sheet = { file: string; yaw: number; stand: number; img: HTMLImageElement; gestures: string[]; walk: number[] };
|
|
72
72
|
/** height: metres standing (a grown-up 1.62, a cat 0.3), so each is drawn at its own size; upright false: an animal, drawn as painted. */
|
|
73
|
-
type Figure = { img: HTMLImageElement; face?: PaintedFace; faceInfo?: FaceDefinition; height?: number; upright?: boolean; rig?: { rig: Rig; parts: Map<PartName, HTMLImageElement>; sheet?: HTMLImageElement; canvas: HTMLCanvasElement }; model?: Figure3D; sprites?: { info: SpriteInfo; sheets: Sheet[]; talk: Sheet[] } };
|
|
73
|
+
type Figure = { img: HTMLImageElement; face?: PaintedFace; faceInfo?: FaceDefinition; height?: number; upright?: boolean; share?: number; rig?: { rig: Rig; parts: Map<PartName, HTMLImageElement>; sheet?: HTMLImageElement; canvas: HTMLCanvasElement }; model?: Figure3D; sprites?: { info: SpriteInfo; sheets: Sheet[]; talk: Sheet[] } };
|
|
74
74
|
/** y: the floor row the cut stands on, which sets how far it slides with parallax. */
|
|
75
75
|
/** Things that float and ride the swell, by their names. */
|
|
76
76
|
const FLOATS = /\b(boat|rowboat|dinghy|skiff|canoe|kayak|raft|ship|barge|punt|gondola|sampan|junk|ferry|trawler|yacht|sailboat)s?\b/i;
|
|
@@ -103,6 +103,8 @@ type Place = {
|
|
|
103
103
|
surf?: Surf;
|
|
104
104
|
/** Painted waterfalls pouring, painted animals swimming and flying, cloud bands drifting (see living.ts). */
|
|
105
105
|
falls?: Fall[];
|
|
106
|
+
/** Small things painted falling (code rain, snow, petals), column by column (see living.ts). */
|
|
107
|
+
streams?: Stream[];
|
|
106
108
|
critters?: Critter[];
|
|
107
109
|
clouds?: Clouds;
|
|
108
110
|
far?: { sky?: HTMLImageElement; flats: Array<{ img: HTMLImageElement; box: [number, number, number, number]; d: number }> };
|
|
@@ -386,7 +388,7 @@ class Game {
|
|
|
386
388
|
await Promise.all(Object.entries(this.data.cast).map(async ([key, cast]) => {
|
|
387
389
|
const img = await loadImage(files(cast.image)).catch(() => undefined);
|
|
388
390
|
if (!img) return;
|
|
389
|
-
const figure: Figure = { img, height: cast.height, upright: cast.upright };
|
|
391
|
+
const figure: Figure = { img, height: cast.height, upright: cast.upright, share: cast.share };
|
|
390
392
|
if (cast.rig && cast.parts) {
|
|
391
393
|
const parts = new Map<PartName, HTMLImageElement>();
|
|
392
394
|
await Promise.all(cast.rig.parts.map(async (p) => parts.set(p.name, await loadImage(files(`${cast.parts}/${p.file}`)))));
|
|
@@ -492,12 +494,16 @@ class Game {
|
|
|
492
494
|
}
|
|
493
495
|
|
|
494
496
|
/** The waterfalls, animals and cloud bands a place's layers set moving. */
|
|
495
|
-
private async living(layers: Layers): Promise<Pick<Place, 'falls' | 'critters' | 'clouds'>> {
|
|
497
|
+
private async living(layers: Layers): Promise<Pick<Place, 'falls' | 'streams' | 'critters' | 'clouds'>> {
|
|
496
498
|
const img = (p: string) => loadImage(files(p)).catch(() => undefined);
|
|
497
499
|
const falls = (await Promise.all((layers.falls ?? []).map(async (f) => {
|
|
498
500
|
const i = await img(f.file);
|
|
499
501
|
return i && new Fall(i, f.box);
|
|
500
502
|
}))).filter((f): f is Fall => !!f);
|
|
503
|
+
const streams = (await Promise.all((layers.streams ?? []).map(async (f, k) => {
|
|
504
|
+
const [i, r] = await Promise.all([img(f.file), img(f.region)]);
|
|
505
|
+
return i && r && new Stream(i, r, f.box, k);
|
|
506
|
+
}))).filter((f): f is Stream => !!f);
|
|
501
507
|
const critters = (await Promise.all((layers.critters ?? []).map(async (c, k) => {
|
|
502
508
|
const i = await img(c.file);
|
|
503
509
|
return i && new Critter(i, c.box, c.medium, c.facing, k);
|
|
@@ -508,7 +514,7 @@ class Game {
|
|
|
508
514
|
const [clip, keep, ...bands] = await Promise.all([img(cl.clip), cl.keep ? img(cl.keep) : Promise.resolve(undefined), ...cl.bands.map((b) => img(b.file))]);
|
|
509
515
|
if (clip && bands.every(Boolean)) clouds = new Clouds(cl.bands.map((b, i) => ({ img: bands[i]!, box: b.box, speed: b.speed })), clip, keep, cl.box);
|
|
510
516
|
}
|
|
511
|
-
return { falls, critters, clouds };
|
|
517
|
+
return { falls, streams, critters, clouds };
|
|
512
518
|
}
|
|
513
519
|
|
|
514
520
|
/** A place, loaded once: a second ask while it loads (the preload, an NPC's walk) waits on the same files. */
|
|
@@ -1274,13 +1280,39 @@ class Game {
|
|
|
1274
1280
|
thingAt(x: number, y: number): Hot | undefined {
|
|
1275
1281
|
const g = this.place.g;
|
|
1276
1282
|
const gone = this.gone();
|
|
1283
|
+
const [sx0, sy0] = [x, y];
|
|
1277
1284
|
for (let i = this.drawn.length - 1; i >= 0; i--) {
|
|
1278
1285
|
const [x0, y0, x1, y1] = this.drawn[i].rect;
|
|
1279
1286
|
// The rect includes the shadow; trim it and the transparent margin at the sides.
|
|
1280
1287
|
const inset = (x1 - x0) * 0.12;
|
|
1281
|
-
if (x >= x0 + inset && x <= x1 - inset && y >= y0 && y <= y1 - 4)
|
|
1288
|
+
if (!(x >= x0 + inset && x <= x1 - inset && y >= y0 && y <= y1 - 4)) continue;
|
|
1289
|
+
// A person's rect is wider than the person: where nobody was drawn over a painted thing (a potion at a
|
|
1290
|
+
// scientist's feet), the thing is what is under the pointer.
|
|
1291
|
+
const under = this.drawnOver(sx0, sy0) === false ? this.paintedAt(sx0, sy0, gone) : undefined;
|
|
1292
|
+
return under?.mask ? under : this.drawn[i].hot;
|
|
1282
1293
|
}
|
|
1283
|
-
|
|
1294
|
+
return this.paintedAt(sx0, sy0, gone);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/**
|
|
1298
|
+
* Whether anyone was drawn over the frame point in the last frame: the canvas there differs from the place as
|
|
1299
|
+
* drawn without anyone (the depth snapshot). Undefined without a snapshot.
|
|
1300
|
+
*/
|
|
1301
|
+
private drawnOver(x: number, y: number): boolean | undefined {
|
|
1302
|
+
if (!this.snapped || !this.snap) return undefined;
|
|
1303
|
+
const { width: W, height: H } = this.place.g.frame;
|
|
1304
|
+
const z = this.zoom();
|
|
1305
|
+
const [cx, cy] = [Math.floor(((x - (this.viewX || W / 2)) * z + W / 2) * (canvas.width / W)), Math.floor(((y - (this.viewY || H / 2)) * z + H / 2) * (canvas.height / H))];
|
|
1306
|
+
if (cx < 0 || cy < 0 || cx >= canvas.width || cy >= canvas.height) return undefined;
|
|
1307
|
+
const a = ctx.getImageData(cx, cy, 1, 1).data;
|
|
1308
|
+
const b = this.snap.getContext('2d')!.getImageData(cx, cy, 1, 1).data;
|
|
1309
|
+
return Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]) > 24;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/** The painted thing (not a person) under a frame point, by its own mask where it has one. */
|
|
1313
|
+
private paintedAt(sx0: number, sy0: number, gone: Set<string>): Hot | undefined {
|
|
1314
|
+
const g = this.place.g;
|
|
1315
|
+
let [x, y] = [sx0, sy0];
|
|
1284
1316
|
for (const hot of this.hots()) {
|
|
1285
1317
|
if (this.drawn.some((d) => d.hot === hot)) continue;
|
|
1286
1318
|
if (gone.has(hot.thing.key)) continue;
|
|
@@ -1963,6 +1995,7 @@ class Game {
|
|
|
1963
1995
|
this.place.clouds?.draw(ctx, this.time, this.place.weather?.w.wind ?? 0, (y) => shiftAt(g, p, y));
|
|
1964
1996
|
this.drawWater(p);
|
|
1965
1997
|
for (const f of this.place.falls ?? []) f.draw(ctx, this.time, (y) => shiftAt(g, p, y));
|
|
1998
|
+
for (const f of this.place.streams ?? []) f.draw(ctx, this.time, (y) => shiftAt(g, p, y));
|
|
1966
1999
|
for (const c of this.place.critters ?? []) c.draw(ctx, this.time, (y) => shiftAt(g, p, y));
|
|
1967
2000
|
this.drawFloats(p);
|
|
1968
2001
|
this.drawSmoke(p);
|
|
@@ -2057,15 +2090,19 @@ class Game {
|
|
|
2057
2090
|
// ?people=0: the place alone, for a landscape take; everyone keeps playing, just unseen.
|
|
2058
2091
|
if (nobody) continue;
|
|
2059
2092
|
if (who.npc) {
|
|
2093
|
+
this.bodyTop = undefined;
|
|
2060
2094
|
const drawn = this.drawWho(who, lit, 'body');
|
|
2095
|
+
const bodyTop = this.bodyTop;
|
|
2061
2096
|
this.drawn.push({ hot: who.npc, rect: drawn });
|
|
2062
2097
|
const body = who.npc.npc?.body;
|
|
2063
|
-
this.occlude(drawn, who.y, gone, body?.x, body && (body.foot ?? stepFootY(g, body.x, who.y)));
|
|
2098
|
+
this.occlude(drawn, who.y, gone, body?.x, body && (body.foot ?? stepFootY(g, body.x, who.y)), bodyTop);
|
|
2064
2099
|
} else {
|
|
2100
|
+
this.bodyTop = undefined;
|
|
2065
2101
|
const drawn = this.drawWho(who, lit, 'body');
|
|
2102
|
+
const bodyTop = this.bodyTop;
|
|
2066
2103
|
if (who.body === this.hero) rect = drawn;
|
|
2067
2104
|
// The depth map goes by where the feet are drawn: on a stair's tread, not at the walker's row.
|
|
2068
|
-
this.occlude(drawn, who.y, gone, who.body?.x, who.body && (who.body.foot ?? stepFootY(g, who.body.x, who.y)));
|
|
2105
|
+
this.occlude(drawn, who.y, gone, who.body?.x, who.body && (who.body.foot ?? stepFootY(g, who.body.x, who.y)), bodyTop);
|
|
2069
2106
|
}
|
|
2070
2107
|
}
|
|
2071
2108
|
burn();
|
|
@@ -2136,14 +2173,14 @@ class Game {
|
|
|
2136
2173
|
* than the feet. Painted figures and painted things are equally lit and equally sharp; this occlusion is
|
|
2137
2174
|
* the cue that puts the figure in front. ctx is already shifted for parallax.
|
|
2138
2175
|
*/
|
|
2139
|
-
private separate(x: number, y: number, h: number, paint: (c: CanvasRenderingContext2D) => number) {
|
|
2176
|
+
private separate(x: number, y: number, h: number, paint: (c: CanvasRenderingContext2D) => number, tall = h) {
|
|
2140
2177
|
const { focus: d, g } = this.place;
|
|
2141
2178
|
const feet = y - g.scale.horizonY;
|
|
2142
2179
|
if (!d || separateScale <= 0 || feet < 4 || h < 8) return;
|
|
2143
2180
|
const blur = h * SEPARATE_BLUR;
|
|
2144
2181
|
const pad = Math.ceil(blur * 2.5 + h * SEPARATE_SHIFT);
|
|
2145
|
-
const [bw, bh] = [Math.ceil(h * 1.2) + 2 * pad, Math.ceil(h *
|
|
2146
|
-
const [ox, oy] = [Math.round(bw / 2), Math.ceil(pad + h *
|
|
2182
|
+
const [bw, bh] = [Math.ceil(Math.max(h * 1.2, tall * 1.2)) + 2 * pad, Math.ceil(tall + h * 0.15) + 2 * pad];
|
|
2183
|
+
const [ox, oy] = [Math.round(bw / 2), Math.ceil(pad + tall + h * 0.1)];
|
|
2147
2184
|
const c = this.sepCanvas;
|
|
2148
2185
|
if (c.width < bw || c.height < bh) Object.assign(c, { width: Math.max(c.width, bw), height: Math.max(c.height, bh) });
|
|
2149
2186
|
const cc = c.getContext('2d')!;
|
|
@@ -2399,6 +2436,10 @@ class Game {
|
|
|
2399
2436
|
// A rig walks its legs; a plain cut-out (or the stand-in) only bobs and rocks as it slides.
|
|
2400
2437
|
const bob = creature ? creature.lift * h : rig || model || sprites || !walking ? 0 : Math.abs(Math.sin(phase * Math.PI * 2)) * h * 0.025;
|
|
2401
2438
|
const rock = creature ? creature.lean * (facing < 0 ? -1 : 1) : rig || model || sprites || !walking ? 0 : Math.sin(phase * Math.PI * 2) * 0.035;
|
|
2439
|
+
// How far above the feet the drawn figure reaches: the body's height, or more when it holds something over
|
|
2440
|
+
// its head (a walk sheet cut with it, or a cut-out's cut.json share). Scratch canvases and boxes are sized by it.
|
|
2441
|
+
const tall = rig ? Math.max(h, (h * rig.rig.ground) / rig.rig.height) : !model && !sprites && figure?.img && figure.share ? h / Math.max(0.2, Math.min(1, figure.share)) : h;
|
|
2442
|
+
this.bodyTop = y - h * 1.04;
|
|
2402
2443
|
let rigCanvas: CanvasImageSource | undefined;
|
|
2403
2444
|
if (rig) {
|
|
2404
2445
|
const at = pose(rig.rig, phase, walking);
|
|
@@ -2446,10 +2487,12 @@ class Game {
|
|
|
2446
2487
|
return Math.max(ax, rig.canvas.width - ax) * s;
|
|
2447
2488
|
}
|
|
2448
2489
|
const img = figure?.img;
|
|
2449
|
-
|
|
2490
|
+
// A cut-out holding something over its head (cut.json share): the body keeps its height, the thing rises above.
|
|
2491
|
+
const ih = h / Math.max(0.2, Math.min(1, figure?.share ?? 1));
|
|
2492
|
+
const w = img ? (ih * img.width) / img.height : h * 0.36;
|
|
2450
2493
|
// A creature squashes and stretches from the feet up, keeping its volume.
|
|
2451
2494
|
const [sx2, sy2] = creature ? [1 / Math.sqrt(creature.stretch), creature.stretch] : [1, 1];
|
|
2452
|
-
if (img) c.drawImage(figure?.face?.draw(img, '$image', 0, 1, 1, articulation, faceTime) ?? img, (-w / 2) * sx2, -
|
|
2495
|
+
if (img) c.drawImage(figure?.face?.draw(img, '$image', 0, 1, 1, articulation, faceTime) ?? img, (-w / 2) * sx2, -ih * sy2, w * sx2, ih * sy2);
|
|
2453
2496
|
else drawFigure(c, h, colour);
|
|
2454
2497
|
return w / 2;
|
|
2455
2498
|
};
|
|
@@ -2467,7 +2510,7 @@ class Game {
|
|
|
2467
2510
|
const breath = 0.5 + 0.5 * Math.sin(t * ((2 * Math.PI) / 3.6));
|
|
2468
2511
|
const m = c.getTransform();
|
|
2469
2512
|
const density = Math.max(1, Math.min(1.5, Math.hypot(m.a, m.b)));
|
|
2470
|
-
return idleWarp(c, paintRaw, token, h, density, sway, k * 0.02 * breath, 0.009 * k * Math.sin(t * ((2 * Math.PI) / 4.1) + 0.7));
|
|
2513
|
+
return idleWarp(c, paintRaw, token, h, density, sway, k * 0.02 * breath, 0.009 * k * Math.sin(t * ((2 * Math.PI) / 4.1) + 0.7), tall);
|
|
2471
2514
|
};
|
|
2472
2515
|
// How much of the floor under the feet lies in a painted shadow: the person stands in it too.
|
|
2473
2516
|
const shaded = Math.max(this.shadeAt(x, y, h), this.fireShadeAt(x, y, h));
|
|
@@ -2507,7 +2550,7 @@ class Game {
|
|
|
2507
2550
|
const bright = this.lightAt(x, y - h * 0.5) * (1 - shaded * strength) * breath;
|
|
2508
2551
|
// A party member is edged in its player's colour (twice, so the edge reads at a glance on a busy painting).
|
|
2509
2552
|
const edge = glow ? ` drop-shadow(0 0 ${Math.max(2, h * 0.008).toFixed(1)}px ${glow}) drop-shadow(0 0 ${Math.max(2, h * 0.008).toFixed(1)}px ${glow})` : '';
|
|
2510
|
-
this.separate(x, y, h, paintRaw);
|
|
2553
|
+
this.separate(x, y, h, paintRaw, tall);
|
|
2511
2554
|
ctx.filter = `brightness(${bright.toFixed(2)})${lit ? ' drop-shadow(0 0 5px rgba(255,225,150,0.95))' : ''}${edge}`;
|
|
2512
2555
|
const frameAt = ctx.getTransform();
|
|
2513
2556
|
ctx.translate(x, y - bob);
|
|
@@ -2520,9 +2563,10 @@ class Game {
|
|
|
2520
2563
|
ctx.setTransform(feetAt);
|
|
2521
2564
|
this.rim(h, paint, bright * (1 - shaded), light);
|
|
2522
2565
|
}
|
|
2523
|
-
this.shadeOver(paint, frameAt, feetAt, [x - half - 4, y -
|
|
2566
|
+
this.shadeOver(paint, frameAt, feetAt, [x - half - 4, y - tall * 1.06 - bob - 4, x + half + 4, y + Math.max(3, h * 0.04) + 4]);
|
|
2524
2567
|
ctx.restore();
|
|
2525
|
-
|
|
2568
|
+
this.bodyTop = y - h * 1.04 - bob - 2 + sy;
|
|
2569
|
+
return [x - half - 2 + sx, y - tall * 1.04 - bob - 2 + sy, x + half + 2 + sx, y + Math.max(3, h * 0.04) + sy];
|
|
2526
2570
|
}
|
|
2527
2571
|
|
|
2528
2572
|
/**
|
|
@@ -2933,16 +2977,18 @@ class Game {
|
|
|
2933
2977
|
if (!light) return 1;
|
|
2934
2978
|
const c = Math.min(light.cols - 1, Math.max(0, Math.floor(x / LIGHT_CELL)));
|
|
2935
2979
|
const r = Math.min(light.rows - 1, Math.max(0, Math.floor(y / LIGHT_CELL)));
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2980
|
+
// The light, not the colour of what lies there: a black tile, a dark table or a blue chair is dark paint in
|
|
2981
|
+
// bright light, so the brighter cells around (the upper quartile of a 5 x 5) say how lit the spot is.
|
|
2982
|
+
const around: number[] = [];
|
|
2983
|
+
for (let dr = -2; dr <= 2; dr++) for (let dc = -2; dc <= 2; dc++) {
|
|
2939
2984
|
const rr = r + dr;
|
|
2940
2985
|
const cc = c + dc;
|
|
2941
2986
|
if (rr < 0 || cc < 0 || rr >= light.rows || cc >= light.cols) continue;
|
|
2942
|
-
|
|
2943
|
-
n++;
|
|
2987
|
+
around.push(light.luma[rr * light.cols + cc]);
|
|
2944
2988
|
}
|
|
2945
|
-
|
|
2989
|
+
around.sort((a, b) => a - b);
|
|
2990
|
+
const lit = around[Math.floor(around.length * 0.75)] ?? 150;
|
|
2991
|
+
return Math.min(1.1, Math.max(0.4, 0.35 + (0.65 * lit) / 150));
|
|
2946
2992
|
}
|
|
2947
2993
|
|
|
2948
2994
|
/** For the explorer: where the depth map puts the painting in front of feet at (x, feetY), over rect (src/qa/probe.ts). */
|
|
@@ -2955,6 +3001,8 @@ class Game {
|
|
|
2955
3001
|
private snap?: HTMLCanvasElement;
|
|
2956
3002
|
private snapped = false;
|
|
2957
3003
|
private depthMask?: HTMLCanvasElement;
|
|
3004
|
+
/** The top of the body of the person drawn last (drawPerson), below anything held over their head. */
|
|
3005
|
+
private bodyTop?: number;
|
|
2958
3006
|
private depthPatch?: HTMLCanvasElement;
|
|
2959
3007
|
|
|
2960
3008
|
/**
|
|
@@ -3035,8 +3083,10 @@ class Game {
|
|
|
3035
3083
|
}
|
|
3036
3084
|
|
|
3037
3085
|
/** Puts the painting back over the hero, column by column, wherever an occluder stands in front of him. */
|
|
3038
|
-
private occlude(rect: [number, number, number, number], feetY: number, gone: Set<string>, feetX?: number, drawnFeetY?: number) {
|
|
3039
|
-
|
|
3086
|
+
private occlude(rect: [number, number, number, number], feetY: number, gone: Set<string>, feetX?: number, drawnFeetY?: number, bodyTop?: number) {
|
|
3087
|
+
// The depth map hides only the body: what is held up over the head (a giant slice of toast) reaches where the
|
|
3088
|
+
// map holds the near ceiling and the wall's top, which never stand in front of someone on the floor.
|
|
3089
|
+
if (this.depthOcclusion) this.occludeDepth(bodyTop !== undefined && bodyTop > rect[1] ? [rect[0], bodyTop, rect[2], rect[3]] : rect, drawnFeetY ?? feetY, feetX);
|
|
3040
3090
|
const support = (k: string) => this.place.byKey.get(k);
|
|
3041
3091
|
for (const cut of this.place.cuts) {
|
|
3042
3092
|
if (cut.occ.thing && gone.has(cut.occ.thing)) continue;
|
|
@@ -3048,7 +3098,9 @@ class Game {
|
|
|
3048
3098
|
const y0 = Math.max(top, cut.y0);
|
|
3049
3099
|
const y1 = Math.min(bottom, cut.y0 + cut.canvas.height);
|
|
3050
3100
|
if (y1 <= y0) continue;
|
|
3051
|
-
|
|
3101
|
+
// Where the feet are, as seen from the card: a figure's box need not be centred on them.
|
|
3102
|
+
const feet = (feetX ?? (rect[0] + rect[2]) / 2) - sx;
|
|
3103
|
+
for (const [a, b] of hiddenRuns(cut.occ, left, right, feetY, support, feet)) {
|
|
3052
3104
|
const xa = Math.max(a, cut.x0);
|
|
3053
3105
|
const xb = Math.min(b, cut.x0 + cut.canvas.width);
|
|
3054
3106
|
if (xb > xa) this.drawCard(cut, xa, xb, y0, y1);
|
|
@@ -3680,8 +3732,9 @@ let idleOwner: { token: object; density: number; half: number } | undefined;
|
|
|
3680
3732
|
* sway x height x u^1.7 (u = height above the feet as a share of the figure), so the feet stay planted and the head
|
|
3681
3733
|
* moves most; the slices above the hips lift by up to `lift` x height (the breath), and the head nods by `nod`.
|
|
3682
3734
|
*/
|
|
3683
|
-
function idleWarp(c: CanvasRenderingContext2D, paint: (c: CanvasRenderingContext2D) => number, token: object, h: number, density: number, sway: number, lift: number, nod: number): number {
|
|
3684
|
-
|
|
3735
|
+
function idleWarp(c: CanvasRenderingContext2D, paint: (c: CanvasRenderingContext2D) => number, token: object, h: number, density: number, sway: number, lift: number, nod: number, tall = h): number {
|
|
3736
|
+
// Wide and high enough for what the figure holds over its head too (tall), else it is cut off flat.
|
|
3737
|
+
const W = Math.ceil(Math.max(h * 1.6, tall * 1.2) * density), H = Math.ceil((tall + h * 0.35) * density);
|
|
3685
3738
|
const foot = Math.ceil(h * 0.15 * density);
|
|
3686
3739
|
if (!(W >= 8 && H >= 8 && W <= 4096 && H <= 4096)) return paint(c);
|
|
3687
3740
|
const scratch = (idleScratch ??= document.createElement('canvas'));
|
package/docs/characters.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
`build` also makes the characters, after the places (steps `cast`, `model`, `sprites`; each also runs alone):
|
|
6
6
|
|
|
7
|
-
- `diorama cast <slug>` paints the hero and every NPC (cast/<key>.png, with what they hold: a lantern or a net SAM's person leaves out stays on, gaps it leaves are filled, and what floats beside them as theirs, a heart or the bubble they are in, comes along) and makes their 2D walk: SAM finds arms, legs and boots to place the joints, then the whole painting is bent by those bones as one fine mesh, not cut into parts, so a coat, an apron or a skirt stretches with the legs and never tears (src/scene/warp.ts). The walk is drawn once into a sheet (cast/<key>/walk.png, 8 frames and the untouched painting for standing; joints in cast/<key>/rig.json) and measured: a walk that opens holes or loses a piece is marked in rig.json `check` and the game slides the painted figure as one piece instead. `diorama rig <slug> [key...]` (Studio agent: `cast_rewalk`) makes the walk again from the paintings already there, with no Muse calls. A drawing SAM does not see as a person is asked as a character; when it still finds none, `cast_status` says why in `rigIssue` and `moves` says how the game moves them instead. Walk sheets whose frames left the pose are listed in `spriteIssues`; `sprites_redo` paints new takes. Spark also says how tall each one stands and whether upright like a person (cast/figures.json, a grown-up 1.62 m): a cat or a child is drawn at its own size, and a creature that is not upright (an animal, a round eye on tiny feet, a living teapot) gets no 2D rig or sprites: it moves as one body, hopping as it goes, squashing as it lands, leaning the way it goes, breathing and blinking (src/play/creature.ts), as its painted figure or, when it is in the sprites plan, as its own 3D model.
|
|
7
|
+
- `diorama cast <slug>` paints the hero and every NPC (cast/<key>.png, with what they hold: a lantern or a net SAM's person leaves out stays on, gaps it leaves are filled, and what floats beside them as theirs, a heart or the bubble they are in, comes along) and makes their 2D walk: SAM finds arms, legs and boots to place the joints, then the whole painting is bent by those bones as one fine mesh, not cut into parts, so a coat, an apron or a skirt stretches with the legs and never tears (src/scene/warp.ts). The walk is drawn once into a sheet (cast/<key>/walk.png, 8 frames and the untouched painting for standing; joints in cast/<key>/rig.json) and measured: a walk that opens holes or loses a piece is marked in rig.json `check` and the game slides the painted figure as one piece instead. What the description says they hold or carry (one Spark call, only when the description says they hold something; the person's own list in cast/fixes.json `holds`, Studio agent: `cast_holds`) is found by SAM on its own, or as what stands out from the plain ground and touches them when SAM finds nothing, and kept on the cut-out and the walk however big it is: a slice of toast held over the head in both hands goes with the body and both arms carry it instead of swinging; cast/<key>/cut.json keeps the list and the share of the cut-out's height the body takes, so the game draws the body at its own height with the thing above it (src/scene/held.ts). `diorama rig <slug> [key...]` (Studio agent: `cast_rewalk`) makes the walk again from the paintings already there, with no Muse calls. A drawing SAM does not see as a person is asked as a character; when it still finds none, `cast_status` says why in `rigIssue` and `moves` says how the game moves them instead. Walk sheets whose frames left the pose are listed in `spriteIssues`; `sprites_redo` paints new takes. Spark also says how tall each one stands and whether upright like a person (cast/figures.json, a grown-up 1.62 m): a cat or a child is drawn at its own size, and a creature that is not upright (an animal, a round eye on tiny feet, a living teapot) gets no 2D rig or sprites: it moves as one body, hopping as it goes, squashing as it lands, leaning the way it goes, breathing and blinking (src/play/creature.ts), as its painted figure or, when it is in the sprites plan, as its own 3D model.
|
|
8
8
|
- `diorama model <slug>` gives each character who needs sprites a rigged 3D model: Muse paints them from four sides, TRELLIS turns the views into a mesh and Make-It-Animatable adds a skeleton (both open Hugging Face Spaces; set `HUGGING_FACE_API_KEY` or `HF_TOKEN`, as they run on your account's GPU quota). The model only supplies poses. A creature (not upright) is painted from four sides as it is (no A-pose, no hands) and keeps TRELLIS's mesh with no skeleton (`model.json` `creature: true`): the game hops, squashes, leans and blinks the whole model, so a creature hero or NPC needs no sheets. Studio's Cast **Animate in 3D** (action `cast_animate`) builds a model for anyone the build skips, and the model step keeps building it. The figure is stood up straight in the file, and the poses start from the shape the skin was bound in (the reconstructed figure), not from whatever pose the rig saved its bones in (Make-It-Animatable's T-pose can sit a figure in a long coat down). What the character wears (a satchel, a backpack) is read off the front view and named in every other view's prompt with the side it shows on; Spark then checks all four together, and a view with it on the wrong side, or with anything standing on the ground at the feet, is painted again (up to 2 rounds; a wrong front repaints all four once). Things held in the hands are left out of the views: a suitcase put down beside the feet would be fused to a boot and swing with the leg in every sprite. A view the image safety filter refuses is asked once more in calm words (the looks left to the picture, the hands said plainly); refused again, the model made before stays, or the character walks with its 2D rig, and the build goes on.
|
|
9
9
|
- Optional local Blender (3.6+, found on PATH, in /Applications, or at `DIORAMA_BLENDER`; on/off in Studio Setup, **3D animation**): after the rig, `src/model/blender/relax.py` puts the figure into a calm rest pose. Muse paints the reference with arms raised and feet wide, TRELLIS builds that pose, and the walk then had to turn each arm ~100° down from it, which pinched the shoulders, splayed the hands sideways and kept the legs straddled. Blender poses arms down by the sides with palms to the thighs and feet under the hips, smooths the bent shoulders and hips (Corrective Smooth) and makes that the rest pose (`model.json` `relaxed: true`, the rig as it came kept in `model.source.glb`). The original surface rides along as the `_REST` vertex attribute, so views fitted and painted in the original pose still land where they belong. `diorama model <slug> [key] --relax` (Studio agent: `cast_relax`) does it for existing models and draws their UV sprites again, with no calls. Without Blender nothing changes.
|
|
10
10
|
- `diorama sprites <slug>` draws their walk and talk sheets (cast/<key>/sprites/): the model walks or gestures in a sheet of 8 frames, Muse draws the sheet again in the world's style, and each frame is cut out and lined up with its pose. A sheet that Muse handed back barely changed is drawn again over a different guide. A walk frame Muse painted out of the gait (a crouch, a sit: the painted figure under 88% of the sheet's usual height) is listed as `off` in sprites.json; the walk steps over it and standing still never shows it. Only what the story uses is drawn: the walk facings the hero's routes need, one talk sheet per speaker in the facing of their conversation, and the gesture Spark picks for each line (`--plan` prints the plan without drawing). Rendering the guides needs Playwright's Chromium (`diorama browsers`).
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Releasing
|
|
2
|
+
|
|
3
|
+
`CHANGELOG.md` decides every npm release of `@gtanczyk/diorama`; nobody runs `npm version` by hand.
|
|
4
|
+
|
|
5
|
+
1. With a change to shipped code (`app/bin`, `app/src`, `app/studio`, `app/web`), add one line under
|
|
6
|
+
`## Unreleased`, written for someone updating Diorama:
|
|
7
|
+
- `### Breaking`: a command, flag or world file layout changed incompatibly (minor before 1.0, major after)
|
|
8
|
+
- `### Added`: something new you can do (minor)
|
|
9
|
+
- `### Fixed`: something that was wrong now works (patch)
|
|
10
|
+
- `### Internal`: refactors, tests, tooling; never releases
|
|
11
|
+
2. Push to main. `release-pr.yaml` checks the changelog, cuts the next version on branch `release/next`
|
|
12
|
+
and opens (or refreshes) the PR **`release: vX.Y.Z`**. Later pushes with more lines update the same PR.
|
|
13
|
+
3. Merge the PR. `publish.yaml` sees `package.json` change, publishes to npm (trusted publishing, no
|
|
14
|
+
token) and creates the `vX.Y.Z` tag and a GitHub Release with that section as notes.
|
|
15
|
+
|
|
16
|
+
Hold a release by leaving the PR open. Set the repository variable `RELEASE_AUTOMERGE=true` to merge it
|
|
17
|
+
by itself, and the secret `RELEASE_TOKEN` (a PAT) if CI must run on the release PR. A version npm already holds is stepped past by patch and never published twice; Actions → Publish to npm → Run workflow
|
|
18
|
+
publishes the version in `package.json` by hand.
|
|
19
|
+
|
|
20
|
+
Locally: `node app/scripts/release.mjs next` (the next version, or none), `notes 0.2.0`, `check`
|
|
21
|
+
(changelog well formed and agreeing with package.json; `--files a,b` also asks for a changelog line
|
|
22
|
+
when those files ship).
|
package/docs/scenes.md
CHANGED
|
@@ -55,7 +55,7 @@ To change it, tell the Studio agent ("no rain in the hut", "it should pour", "wa
|
|
|
55
55
|
|
|
56
56
|
## Checking a world
|
|
57
57
|
|
|
58
|
-
`diorama build` ends with `verify`, which writes `verify.json` per place and prints which places are playable and what was flagged on the way. Places are built side by side and independently, and no place waits for another: each goes through its own steps and is told playable (`place.verdict`) as soon as its verify and free repairs are done, while the rest are still being made. The world's own steps start as soon as what they read is there (`app/src/world/schedule.ts`): the cast is painted from world.json while the places are, voices once the story is, an item's icon once its place is cut out, sprites once every place has its walking routes, sound once every place is painted. A place whose step breaks is set aside with why, and the others go on to the end, characters and sound included. Each model has a cap on requests open at once so the overlap does not run into rate limits (`DIORAMA_LANES="muse=8,spark=12,sam=16"`, the defaults). The report gives each error its playbook (`app/src/scene/playbook.ts`): where to look, the fix tools to try in order, and the costly last resort (a camera too close, `SCALE_NEAR` or a floor cut off once derive shut the front rows for it, is rerun derive first, then relayout and repaint); `place_status` hands the same to the Studio agent, which repairs one place at a time with the fix tools. The `masks` step also checks that each mask holds its thing: one Spark look per place at the painting with every mask box numbered (`app/src/scene/maskcheck.ts`), since a box and its mask can agree and both sit on the neighbour. When Spark points to where the thing really is, that is `MASK_WRONG` in verify (an error for any thing, props too). When it finds the thing nowhere and what it sees in the box is another kind of thing (hay where a horse should be), the thing was never painted: `THING_NOT_PAINTED`, an error whose play is to paint it in. A doubt about details only (a bronze bell for a brass one) is the flag `MASK_DOUBT`. Spark is asked to judge the kind of thing, not its details, and a box someone fixed is taken as given. Before anyone is asked, the build makes the cheap fixes itself (`app/src/scene/autofix.ts`): a gap of up to three 60 px cells with nothing solid in it, or up to six along stairs or a gangway (a walk-on thing) whose mask reaches both floors, is bridged with a floor brush stroke (two passes, as one bridge can bring the next patch within reach; stairs that lead somewhere else, with water between the floors, bridge nothing: that place needs a floor in the layout or a repaint, so it goes to the agent), a stand spot off the floor moves to the nearest reachable point (both without a model call), and a `MASK_WRONG` thing gets the box Spark saw it in (one SAM call). A card flagged `CARD_CUT` gets the cut part brushed onto its mask (a lantern on a boat's gunwale), so the card carries it and the backdrop is made again without it (once per cut). A `THING_NOT_PAINTED` thing left after them gets one Muse edit at its layout box (once per thing, kept as a repaint correction) and the place is rebuilt from paint. They are ordinary corrections, so `corrections_get` lists them and `corrections_clear` takes them back; `build.json` keeps them under `repairs`, and `--repair off` skips them. What the free fixes leave, the Studio hands to the agent at once: a build that ends with places unplayable wakes it with the places and their errors (`budget_set autoRepair:false` turns that off). It repairs with the fix tools in the playbook's order; after three fixes of a place in a row that leave it unplayable the fix tools refuse it and it tells you what it tried
|
|
58
|
+
`diorama build` ends with `verify`, which writes `verify.json` per place and prints which places are playable and what was flagged on the way. Places are built side by side and independently, and no place waits for another: each goes through its own steps and is told playable (`place.verdict`) as soon as its verify and free repairs are done, while the rest are still being made. The world's own steps start as soon as what they read is there (`app/src/world/schedule.ts`): the cast is painted from world.json while the places are, voices once the story is, an item's icon once its place is cut out, sprites once every place has its walking routes, sound once every place is painted. A place whose step breaks is set aside with why, and the others go on to the end, characters and sound included. Each model has a cap on requests open at once so the overlap does not run into rate limits (`DIORAMA_LANES="muse=8,spark=12,sam=16"`, the defaults). The report gives each error its playbook (`app/src/scene/playbook.ts`): where to look, the fix tools to try in order, and the costly last resort (a camera too close, `SCALE_NEAR` or a floor cut off once derive shut the front rows for it, is rerun derive first, then relayout and repaint); `place_status` hands the same to the Studio agent, which repairs one place at a time with the fix tools. The `masks` step also checks that each mask holds its thing: one Spark look per place at the painting with every mask box numbered (`app/src/scene/maskcheck.ts`), since a box and its mask can agree and both sit on the neighbour. When Spark points to where the thing really is, that is `MASK_WRONG` in verify (an error for any thing, props too). When it finds the thing nowhere and what it sees in the box is another kind of thing (hay where a horse should be), the thing was never painted: `THING_NOT_PAINTED`, an error whose play is to paint it in. A doubt about details only (a bronze bell for a brass one) is the flag `MASK_DOUBT`. Spark is asked to judge the kind of thing, not its details, and a box someone fixed is taken as given. Before anyone is asked, the build makes the cheap fixes itself (`app/src/scene/autofix.ts`): a gap of up to three 60 px cells with nothing solid in it, or up to six along stairs or a gangway (a walk-on thing) whose mask reaches both floors, is bridged with a floor brush stroke (two passes, as one bridge can bring the next patch within reach; stairs that lead somewhere else, with water between the floors, bridge nothing: that place needs a floor in the layout or a repaint, so it goes to the agent), a stand spot off the floor moves to the nearest reachable point (both without a model call), and a `MASK_WRONG` thing gets the box Spark saw it in (one SAM call). A card flagged `CARD_CUT` gets the cut part brushed onto its mask (a lantern on a boat's gunwale), so the card carries it and the backdrop is made again without it (once per cut). A `THING_NOT_PAINTED` thing left after them gets one Muse edit at its layout box (once per thing, kept as a repaint correction) and the place is rebuilt from paint. They are ordinary corrections, so `corrections_get` lists them and `corrections_clear` takes them back; `build.json` keeps them under `repairs`, and `--repair off` skips them. What the free fixes leave, the Studio hands to the agent at once: a build that ends with places unplayable wakes it with the places and their errors (`budget_set autoRepair:false` turns that off). It repairs with the fix tools in the playbook's order; after three fixes of a place in a row that leave it unplayable the fix tools refuse an exact repeat of a fix that failed and steer it to a different path (a mask brushed by hand, floor cells, taking a thing off), and after six they keep repaints and relayouts, which cost a Muse call, for you to approve; when the new path fails too it tells you briefly what it tried. `place_view` is how the agent looks, as a picture it sees (layer `walk`, `masks` or `painting`, all on the 60 px grid), so it has no need to dig through files; after three shell calls in a turn it is reminded of that once. Layer `masks` tints each thing's mask against the box it should be in and flags a mask that cut out a neighbour. Layer `walk` shows why a place cannot be walked: reached floor green, unreached red, each gap as a dashed line naming the 60 px cells to bridge (the ones `floor_cells` takes) and the things in it, and stand spots off the floor (`places/<place>/diagnose.png`; the agent gets it as an image). Every step also leaves a picture to look at: `masks/overlay.png` (what SAM found) and `walk.png` (where the hero can walk, footprints, floor lines and his size at a few depths).
|
|
59
59
|
|
|
60
60
|
`diorama explore <slug>` puts the hero on every part of each floor in the real play page and checks each frame; it needs Playwright with Chromium (`diorama browsers` downloads it once). `--fix` closes the floor where he looked wrong.
|
|
61
61
|
|
package/docs/setup.md
CHANGED
|
@@ -32,7 +32,7 @@ npm run doctor -- --now # before building in front of people: is the netw
|
|
|
32
32
|
npm run studio # the GUI
|
|
33
33
|
npm run build -- <slug> "<idea>"
|
|
34
34
|
npm run play -- <slug>
|
|
35
|
-
npm run
|
|
35
|
+
npm run pages -- <slug> # diorama publish (npm keeps the script name publish for itself)
|
|
36
36
|
npm run export -- <slug> --zip
|
|
37
37
|
npm run browsers # Playwright's Chromium, for explore, playthrough and sprites
|
|
38
38
|
npm run diorama -- <command> # any other command, e.g. npm run diorama -- masks <slug>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gtanczyk/diorama",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Turn a story prompt into playable painted point-and-click scenes with Meta Muse and SAM 3.1",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"studio": "node app/bin/diorama.mjs studio",
|
|
43
43
|
"build": "node app/bin/diorama.mjs build",
|
|
44
44
|
"play": "node app/bin/diorama.mjs play",
|
|
45
|
-
"
|
|
45
|
+
"pages": "node app/bin/diorama.mjs publish",
|
|
46
46
|
"export": "node app/bin/diorama.mjs export",
|
|
47
47
|
"browsers": "node app/bin/diorama.mjs browsers",
|
|
48
48
|
"typecheck": "tsc --noEmit -p app && tsc --noEmit -p app/web && tsc --noEmit -p app/studio",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"@meta-sam/parser": "^0.1.0",
|
|
55
55
|
"@preact/signals": "^2.11.3",
|
|
56
56
|
"esbuild": "^0.25.0",
|
|
57
|
-
"genaicode": "^2.
|
|
57
|
+
"genaicode": "^2.28.1",
|
|
58
58
|
"onnxruntime-web": "^1.31.1",
|
|
59
59
|
"playwright": "^1.63.0",
|
|
60
60
|
"preact": "^10.29.8",
|