@gtanczyk/diorama 0.0.0-stage → 0.2.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/LICENSE +202 -0
- package/README.md +157 -2
- package/app/bin/diorama.mjs +13 -0
- package/app/src/actions/budget.ts +160 -0
- package/app/src/actions/build.ts +158 -0
- package/app/src/actions/cast.ts +267 -0
- package/app/src/actions/depth.ts +95 -0
- package/app/src/actions/extend.ts +36 -0
- package/app/src/actions/faces.ts +160 -0
- package/app/src/actions/feedback.ts +87 -0
- package/app/src/actions/fix.ts +382 -0
- package/app/src/actions/history.ts +93 -0
- package/app/src/actions/idea-preview.ts +65 -0
- package/app/src/actions/idea-review.ts +102 -0
- package/app/src/actions/idea-sketch.ts +172 -0
- package/app/src/actions/index.ts +32 -0
- package/app/src/actions/items.ts +94 -0
- package/app/src/actions/kids.ts +215 -0
- package/app/src/actions/language.ts +52 -0
- package/app/src/actions/models.ts +64 -0
- package/app/src/actions/motion.ts +119 -0
- package/app/src/actions/pictures.ts +49 -0
- package/app/src/actions/reactions.ts +36 -0
- package/app/src/actions/registry.ts +101 -0
- package/app/src/actions/share.ts +32 -0
- package/app/src/actions/sound.ts +73 -0
- package/app/src/actions/start.ts +154 -0
- package/app/src/actions/step-work.ts +150 -0
- package/app/src/actions/story.ts +101 -0
- package/app/src/actions/studio.ts +133 -0
- package/app/src/actions/style.ts +72 -0
- package/app/src/actions/video.ts +46 -0
- package/app/src/actions/view.ts +109 -0
- package/app/src/actions/weather.ts +38 -0
- package/app/src/actions/world.ts +290 -0
- package/app/src/commands/action.ts +39 -0
- package/app/src/commands/backdrop.ts +188 -0
- package/app/src/commands/browsers.ts +21 -0
- package/app/src/commands/build.ts +503 -0
- package/app/src/commands/cast.ts +161 -0
- package/app/src/commands/clip.ts +431 -0
- package/app/src/commands/derive.ts +167 -0
- package/app/src/commands/doctor.ts +74 -0
- package/app/src/commands/explore.ts +123 -0
- package/app/src/commands/export.ts +158 -0
- package/app/src/commands/extend.ts +75 -0
- package/app/src/commands/faces.ts +80 -0
- package/app/src/commands/icons.ts +69 -0
- package/app/src/commands/index.ts +37 -0
- package/app/src/commands/language.ts +87 -0
- package/app/src/commands/layout.ts +31 -0
- package/app/src/commands/masks.ts +117 -0
- package/app/src/commands/mcp.ts +52 -0
- package/app/src/commands/model.ts +284 -0
- package/app/src/commands/paint.ts +182 -0
- package/app/src/commands/plates.ts +97 -0
- package/app/src/commands/play.ts +195 -0
- package/app/src/commands/playthrough.ts +111 -0
- package/app/src/commands/publish.ts +78 -0
- package/app/src/commands/reactions.ts +40 -0
- package/app/src/commands/reel.ts +46 -0
- package/app/src/commands/setup.ts +85 -0
- package/app/src/commands/sound.ts +100 -0
- package/app/src/commands/sprites.ts +232 -0
- package/app/src/commands/story.ts +37 -0
- package/app/src/commands/studio.ts +65 -0
- package/app/src/commands/style.ts +87 -0
- package/app/src/commands/verify.ts +45 -0
- package/app/src/commands/voice-retry.ts +14 -0
- package/app/src/commands/voices.ts +145 -0
- package/app/src/commands/weather.ts +52 -0
- package/app/src/commands/world.ts +104 -0
- package/app/src/film/clip.ts +386 -0
- package/app/src/film/reel.ts +126 -0
- package/app/src/film/screencast.ts +123 -0
- package/app/src/image/mask.ts +29 -0
- package/app/src/image/overlay.ts +38 -0
- package/app/src/image/raster.ts +224 -0
- package/app/src/main.ts +28 -0
- package/app/src/meta/choice.ts +27 -0
- package/app/src/meta/http.ts +99 -0
- package/app/src/meta/memo.ts +105 -0
- package/app/src/meta/muse.ts +70 -0
- package/app/src/meta/openrouter.ts +93 -0
- package/app/src/meta/prices.ts +25 -0
- package/app/src/meta/sam.ts +83 -0
- package/app/src/meta/schema.ts +63 -0
- package/app/src/meta/spark.ts +220 -0
- package/app/src/meta/step.ts +14 -0
- package/app/src/meta/voice-file.ts +78 -0
- package/app/src/meta/voice.ts +103 -0
- package/app/src/model/animation.ts +38 -0
- package/app/src/model/blender/relax.py +284 -0
- package/app/src/model/blender.ts +115 -0
- package/app/src/model/build.ts +50 -0
- package/app/src/model/face.ts +21 -0
- package/app/src/model/fit.ts +181 -0
- package/app/src/model/glb.ts +241 -0
- package/app/src/model/plan.ts +151 -0
- package/app/src/model/quality.ts +48 -0
- package/app/src/model/space.ts +168 -0
- package/app/src/model/sprites.ts +293 -0
- package/app/src/model/views.ts +163 -0
- package/app/src/play/creature.ts +43 -0
- package/app/src/play/depth-occlusion.ts +150 -0
- package/app/src/play/exit-outline.ts +48 -0
- package/app/src/play/face3d.ts +34 -0
- package/app/src/play/focus.ts +78 -0
- package/app/src/play/geometry.ts +272 -0
- package/app/src/play/gestures.ts +23 -0
- package/app/src/play/glow.ts +187 -0
- package/app/src/play/lipsync.ts +69 -0
- package/app/src/play/living.ts +83 -0
- package/app/src/play/outline-path.ts +130 -0
- package/app/src/play/parallax.ts +200 -0
- package/app/src/play/path.ts +262 -0
- package/app/src/play/preview.ts +71 -0
- package/app/src/play/reactions.ts +92 -0
- package/app/src/play/rig.ts +172 -0
- package/app/src/play/shadow.ts +253 -0
- package/app/src/play/sound.ts +51 -0
- package/app/src/play/weather.ts +58 -0
- package/app/src/qa/explore.ts +136 -0
- package/app/src/qa/probe.ts +139 -0
- package/app/src/qa/route.ts +118 -0
- package/app/src/registry.ts +23 -0
- package/app/src/scene/animation-source.ts +36 -0
- package/app/src/scene/assets.ts +37 -0
- package/app/src/scene/autofix.ts +133 -0
- package/app/src/scene/backdrop.ts +326 -0
- package/app/src/scene/blockin.ts +189 -0
- package/app/src/scene/brief.ts +74 -0
- package/app/src/scene/carry.ts +177 -0
- package/app/src/scene/cast-fixes.ts +33 -0
- package/app/src/scene/cast.ts +199 -0
- package/app/src/scene/corrections.ts +164 -0
- package/app/src/scene/critic.ts +169 -0
- package/app/src/scene/depthmap.ts +149 -0
- package/app/src/scene/derive.ts +475 -0
- package/app/src/scene/diagnose.ts +132 -0
- package/app/src/scene/face-calibration.ts +65 -0
- package/app/src/scene/face-registration.ts +21 -0
- package/app/src/scene/face-work.ts +71 -0
- package/app/src/scene/faces-data.ts +48 -0
- package/app/src/scene/faces-settings.ts +20 -0
- package/app/src/scene/faces.ts +435 -0
- package/app/src/scene/glows.ts +153 -0
- package/app/src/scene/held.ts +109 -0
- package/app/src/scene/icons.ts +185 -0
- package/app/src/scene/layers.ts +822 -0
- package/app/src/scene/layout.ts +216 -0
- package/app/src/scene/locate.ts +118 -0
- package/app/src/scene/maskcheck.ts +43 -0
- package/app/src/scene/masks.ts +392 -0
- package/app/src/scene/occluders.ts +123 -0
- package/app/src/scene/occlusion-check.ts +138 -0
- package/app/src/scene/paint-layout.ts +135 -0
- package/app/src/scene/paint.ts +81 -0
- package/app/src/scene/photo.ts +123 -0
- package/app/src/scene/plates.ts +256 -0
- package/app/src/scene/playbook.ts +121 -0
- package/app/src/scene/preview.ts +23 -0
- package/app/src/scene/reactions.ts +118 -0
- package/app/src/scene/restyle.ts +53 -0
- package/app/src/scene/rig-check.ts +68 -0
- package/app/src/scene/rig.ts +379 -0
- package/app/src/scene/scale.ts +148 -0
- package/app/src/scene/shadows.ts +215 -0
- package/app/src/scene/stairs.ts +183 -0
- package/app/src/scene/status.ts +32 -0
- package/app/src/scene/verify.ts +156 -0
- package/app/src/scene/walk.ts +326 -0
- package/app/src/scene/warp.ts +419 -0
- package/app/src/scene/weather.ts +118 -0
- package/app/src/scene/words.ts +65 -0
- package/app/src/secrets.ts +219 -0
- package/app/src/setup/checks.ts +110 -0
- package/app/src/sound/cost.ts +74 -0
- package/app/src/sound/data.ts +89 -0
- package/app/src/sound/fixes.ts +23 -0
- package/app/src/sound/issues.ts +21 -0
- package/app/src/sound/language.ts +11 -0
- package/app/src/sound/lipsync-make.ts +98 -0
- package/app/src/sound/lipsync.ts +24 -0
- package/app/src/sound/make.ts +229 -0
- package/app/src/sound/musicgen.py +20 -0
- package/app/src/sound/pcm.ts +280 -0
- package/app/src/sound/plan.ts +154 -0
- package/app/src/sound/retry.ts +43 -0
- package/app/src/sound/settings.ts +28 -0
- package/app/src/sound/sources.ts +81 -0
- package/app/src/sound/spoken.ts +70 -0
- package/app/src/sound/voices.ts +579 -0
- package/app/src/speech/transcribe.ts +38 -0
- package/app/src/speech/types.ts +18 -0
- package/app/src/story/check.ts +230 -0
- package/app/src/story/design.ts +108 -0
- package/app/src/story/engine.ts +233 -0
- package/app/src/story/fixes.ts +54 -0
- package/app/src/story/npc-guidance.ts +7 -0
- package/app/src/story/phrases.ts +4 -0
- package/app/src/story/schema.ts +92 -0
- package/app/src/studio/agent.ts +555 -0
- package/app/src/studio/discovery.ts +28 -0
- package/app/src/studio/events.ts +48 -0
- package/app/src/studio/history.ts +124 -0
- package/app/src/studio/jobs.ts +291 -0
- package/app/src/studio/locks.ts +33 -0
- package/app/src/studio/mcp.ts +86 -0
- package/app/src/studio/operation.ts +18 -0
- package/app/src/studio/repair.ts +31 -0
- package/app/src/studio/server.ts +423 -0
- package/app/src/studio/trouble.ts +48 -0
- package/app/src/world/design.ts +72 -0
- package/app/src/world/drawings.ts +198 -0
- package/app/src/world/extend-stage.ts +109 -0
- package/app/src/world/extend.ts +123 -0
- package/app/src/world/extension-progress.ts +10 -0
- package/app/src/world/files.ts +62 -0
- package/app/src/world/idea-review.ts +32 -0
- package/app/src/world/language.ts +116 -0
- package/app/src/world/photo-limits.ts +2 -0
- package/app/src/world/pool.ts +41 -0
- package/app/src/world/schedule.ts +64 -0
- package/app/src/world/schema.ts +74 -0
- package/app/src/world/sketch.ts +81 -0
- package/app/src/world/style-plan.ts +90 -0
- package/app/src/world/style-reference.ts +16 -0
- package/app/src/world/styled.ts +51 -0
- package/app/src/world/told.ts +105 -0
- package/app/src/world/video-limits.ts +3 -0
- package/app/src/world/video.ts +98 -0
- package/app/studio/activity.tsx +124 -0
- package/app/studio/agent.tsx +384 -0
- package/app/studio/api.ts +111 -0
- package/app/studio/atelier.tsx +234 -0
- package/app/studio/audio-missing.tsx +25 -0
- package/app/studio/audio-setup.tsx +33 -0
- package/app/studio/blender-setup.tsx +30 -0
- package/app/studio/build.tsx +679 -0
- package/app/studio/camera.tsx +59 -0
- package/app/studio/cast-work.tsx +207 -0
- package/app/studio/cast.tsx +178 -0
- package/app/studio/concept-edit.tsx +47 -0
- package/app/studio/creator-idea.tsx +46 -0
- package/app/studio/custom-look.tsx +36 -0
- package/app/studio/faces-setup.tsx +28 -0
- package/app/studio/favicon.svg +11 -0
- package/app/studio/figure.tsx +314 -0
- package/app/studio/fonts/OFL.txt +93 -0
- package/app/studio/fonts/fonts.css +7 -0
- package/app/studio/fonts/fraunces-italic-latin-ext.woff2 +0 -0
- package/app/studio/fonts/fraunces-italic-latin.woff2 +0 -0
- package/app/studio/fonts/fraunces-normal-latin-ext.woff2 +0 -0
- package/app/studio/fonts/fraunces-normal-latin.woff2 +0 -0
- package/app/studio/fonts/instrument-sans-normal-latin-ext.woff2 +0 -0
- package/app/studio/fonts/instrument-sans-normal-latin.woff2 +0 -0
- package/app/studio/icons.tsx +42 -0
- package/app/studio/idea-card.tsx +42 -0
- package/app/studio/idea-review.tsx +181 -0
- package/app/studio/idea-storyboard.tsx +134 -0
- package/app/studio/index.html +15 -0
- package/app/studio/job-words.ts +33 -0
- package/app/studio/lightbox.tsx +49 -0
- package/app/studio/main.tsx +1329 -0
- package/app/studio/nav.ts +14 -0
- package/app/studio/place.tsx +264 -0
- package/app/studio/review-sketch.tsx +47 -0
- package/app/studio/say-it.tsx +49 -0
- package/app/studio/step-work.tsx +134 -0
- package/app/studio/story.tsx +394 -0
- package/app/studio/style-menu.tsx +81 -0
- package/app/studio/style.css +1390 -0
- package/app/studio/sure.tsx +54 -0
- package/app/studio/tip.tsx +74 -0
- package/app/studio/tsconfig.json +5 -0
- package/app/studio/video.tsx +73 -0
- package/app/studio/world.tsx +188 -0
- package/app/tsconfig.json +13 -0
- package/app/web/audio.ts +315 -0
- package/app/web/face.ts +131 -0
- package/app/web/face3d.ts +35 -0
- package/app/web/glows.ts +387 -0
- package/app/web/hero3d.ts +728 -0
- package/app/web/index.html +118 -0
- package/app/web/living.ts +404 -0
- package/app/web/main.ts +3769 -0
- package/app/web/pad.ts +174 -0
- package/app/web/reactions.ts +79 -0
- package/app/web/sheet.ts +36 -0
- package/app/web/synth.ts +140 -0
- package/app/web/tsconfig.json +5 -0
- package/app/web/ui.ts +263 -0
- package/app/web/weather.ts +306 -0
- package/app/web/words.ts +12 -0
- package/docs/characters.md +58 -0
- package/docs/npc-motion.md +69 -0
- package/docs/scenes.md +75 -0
- package/docs/setup.md +60 -0
- package/docs/sound.md +23 -0
- package/docs/studio.md +62 -0
- package/package.json +70 -4
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
// The story at a glance: places as a map joined by their exits, locked exits marked, endings on the right,
|
|
2
|
+
// and the checker's verdict with a shortest route to each ending. Pick a place to read and reword what
|
|
3
|
+
// happens there; anything that changes what the player can do is asked of the agent (story_redo).
|
|
4
|
+
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
|
5
|
+
import { askAgent, call } from './api.ts';
|
|
6
|
+
|
|
7
|
+
type Effect = { set?: string; clear?: string; give?: string; take?: string; go?: string; ending?: string; moveNpc?: { npc: string; to: string } };
|
|
8
|
+
type Interaction = { place: string; thing: string; verb: string; with?: string; when: string[]; say: string; do: Effect[]; once: boolean };
|
|
9
|
+
type Choice = { text: string; when: string[]; do: Effect[]; next?: string };
|
|
10
|
+
type Dialogue = { npc: string; place: string; when: string[]; start: string; lines: { key: string; says: string; choices: Choice[] }[] };
|
|
11
|
+
type Story = {
|
|
12
|
+
intro: string;
|
|
13
|
+
items: { key: string; name: string }[];
|
|
14
|
+
locks: { place: string; via: string; until: string[]; say: string }[];
|
|
15
|
+
interactions: Interaction[];
|
|
16
|
+
dialogues: Dialogue[];
|
|
17
|
+
changes: { place: string; thing: string; when: string[]; looks: string }[];
|
|
18
|
+
patrols?: { npc: string; place: string; via: string[]; when: string[]; pause: number }[];
|
|
19
|
+
endings: { key: string; title: string; text: string }[];
|
|
20
|
+
};
|
|
21
|
+
type Place = { key: string; shows: string; exits: { to: string; via: string }[]; things: { key: string; role: string }[] };
|
|
22
|
+
type Status = {
|
|
23
|
+
start: string; hero: string; places: Place[]; story: Story | null;
|
|
24
|
+
fixes: { wish?: string; texts: Record<string, { text: string; at: string }> };
|
|
25
|
+
issues: string[]; states: number; routes: { ending: string; steps: string[] }[];
|
|
26
|
+
long: string[]; unanswerable?: string[]; unseen?: string[]; unvoiced: string[]; unpainted: number[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Word limits the designer keeps to (src/story/check.ts BREVITY); shown, never enforced, on hand edits. */
|
|
30
|
+
const LIMIT = { intro: 30, say: 14, says: 20, choice: 8, ending: 25 } as const;
|
|
31
|
+
const words = (t: string) => t.trim().split(/\s+/).filter(Boolean).length;
|
|
32
|
+
|
|
33
|
+
const fact = (f: string) => f.replace(/^!has:(.*)/, 'without $1').replace(/^has:(.*)/, 'has $1').replace(/^at:(.*)/, 'at $1').replace(/^!(.*)/, 'not $1');
|
|
34
|
+
const effect = (e: Effect) => e.moveNpc ? `${e.moveNpc.npc} walks to ${e.moveNpc.to}`
|
|
35
|
+
: Object.entries(e).map(([k, v]) => (k === 'go' ? `go to ${v}` : k === 'ending' ? `ends: ${v}` : `${k} ${v}`)).join(' ');
|
|
36
|
+
|
|
37
|
+
/** One thing the story step did while writing: a draft from the model, or what the checker found in it. */
|
|
38
|
+
export type StoryProgress = { at: number; text: string; kind: 'call' | 'check'; by?: string };
|
|
39
|
+
type Writing = { since?: number; log: StoryProgress[] };
|
|
40
|
+
|
|
41
|
+
export function StoryView({ slug, version, onError, writing }: { slug: string; version: number; onError: (m: string) => void; writing?: Writing }) {
|
|
42
|
+
const [status, setStatus] = useState<Status | null>(null);
|
|
43
|
+
const [pick, setPick] = useState<string | null>(null);
|
|
44
|
+
const [asking, setAsking] = useState(false);
|
|
45
|
+
const [asked, setAsked] = useState(false);
|
|
46
|
+
useEffect(() => setAsked(false), [slug]);
|
|
47
|
+
const load = () => call<Status>('story_status', { slug }).then(setStatus).catch((e) => onError(e.message));
|
|
48
|
+
useEffect(() => void load(), [slug, version]);
|
|
49
|
+
if (!status) return <p class="detail">Reading the story…</p>;
|
|
50
|
+
const { story } = status;
|
|
51
|
+
if (!story) return <PlanOnly status={status} writing={writing} />;
|
|
52
|
+
const save = (path: string, text: string) => call('story_text', { slug, path, text }).then(load).catch((e) => onError(e.message));
|
|
53
|
+
const ask = async (step: 'voices' | 'plates') => {
|
|
54
|
+
if (asking) return;
|
|
55
|
+
setAsking(true);
|
|
56
|
+
setAsked(false);
|
|
57
|
+
try {
|
|
58
|
+
await askAgent(slug,
|
|
59
|
+
`Review the story's missing ${step === 'voices' ? 'spoken audio' : 'painted changes'}. Read story_status to check what is currently missing, explain the scope and estimated generation cost (say if the cost is unknown), and ask me to confirm before making any paid generation calls. This request is for review only; do not run generation yet.`,
|
|
60
|
+
step === 'voices' ? 'Review missing story voices and cost' : 'Review missing story paintings and cost');
|
|
61
|
+
setAsked(true);
|
|
62
|
+
} catch (e) {
|
|
63
|
+
onError((e as Error).message);
|
|
64
|
+
} finally {
|
|
65
|
+
setAsking(false);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const place = status.places.find((p) => p.key === pick);
|
|
69
|
+
const ending = story.endings.find((e) => `ending:${e.key}` === pick);
|
|
70
|
+
return (
|
|
71
|
+
<div class="story">
|
|
72
|
+
{writing && <Writing writing={writing} again />}
|
|
73
|
+
<Verdict status={status} onAsk={ask} asking={asking} onFirst={() => call('story_redo', { slug }).catch((e) => onError(e.message))} />
|
|
74
|
+
{asked && <p class="detail" role="status">Sent to the agent. Review the scope and cost in the conversation before approving generation.</p>}
|
|
75
|
+
<StoryMap key={slug} status={status} story={story} pick={pick} onPick={(k) => setPick(pick === k ? null : k)} />
|
|
76
|
+
{place
|
|
77
|
+
? <PlaceStory place={place} story={story} hero={status.hero} unpainted={status.unpainted} onSave={save} />
|
|
78
|
+
: ending
|
|
79
|
+
? <EndingStory story={story} ending={ending.key} route={status.routes.find((r) => r.ending === ending.key)?.steps ?? null} onSave={save} />
|
|
80
|
+
: <Overview story={story} onSave={save} />}
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Before the story step has written story.json, the places step has already planned the world:
|
|
87
|
+
* show that plan (what the story will be written over) rather than an empty page.
|
|
88
|
+
*/
|
|
89
|
+
function PlanOnly({ status, writing }: { status: Status; writing?: Writing }) {
|
|
90
|
+
return (
|
|
91
|
+
<div class="story plan-only">
|
|
92
|
+
{writing
|
|
93
|
+
? <Writing writing={writing} />
|
|
94
|
+
: <p class="detail">The story is not written yet: the story step of the build writes it over this plan. It shows here when done.</p>}
|
|
95
|
+
<p class="detail">Hero: <strong>{status.hero}</strong> · starts in <strong>{status.start}</strong></p>
|
|
96
|
+
<ul class="plan-places">
|
|
97
|
+
{status.places.map((p) => (
|
|
98
|
+
<li>
|
|
99
|
+
<strong>{p.key}</strong>
|
|
100
|
+
<span>{p.shows}</span>
|
|
101
|
+
{!!p.things.length && <span class="detail">Things: {p.things.map((t) => t.key).join(', ')}</span>}
|
|
102
|
+
{!!p.exits.length && <span class="detail">Exits: {p.exits.map((e) => `${e.to} (${e.via})`).join(', ')}</span>}
|
|
103
|
+
</li>
|
|
104
|
+
))}
|
|
105
|
+
</ul>
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The story step at work: a draft, the checker plays every state and lists what breaks, the next draft fixes it
|
|
112
|
+
* (up to 4), then a dialogue review. Each of those lands here as it happens, with the step's own clock.
|
|
113
|
+
*/
|
|
114
|
+
function Writing({ writing, again }: { writing: Writing; again?: boolean }) {
|
|
115
|
+
const [now, setNow] = useState(Date.now());
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
const t = setInterval(() => setNow(Date.now()), 1000);
|
|
118
|
+
return () => clearInterval(t);
|
|
119
|
+
}, []);
|
|
120
|
+
const secs = writing.since ? Math.max(0, Math.round((now - writing.since) / 1000)) : null;
|
|
121
|
+
const drafts = writing.log.filter((l) => l.kind === 'call' && l.text.startsWith('draft')).length;
|
|
122
|
+
const doing = writing.log.at(-1)?.kind === 'call' && drafts ? (writing.log.at(-1)!.text.startsWith('draft') ? 'the checker plays it through' : 'deciding') : drafts ? `writing draft ${drafts + 1} of up to 4` : 'writing the first draft';
|
|
123
|
+
return (
|
|
124
|
+
<section class="story-writing" role="status" aria-live="polite">
|
|
125
|
+
<strong>{again ? 'Rewriting the story' : 'Writing the story'}{secs !== null ? ` · ${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, '0')}` : ''}</strong>
|
|
126
|
+
<span class="detail">Now: {doing}. Each draft is played through every state by a checker; what breaks goes back for the next draft (up to 4), then the dialogue is reviewed.{again ? ' Below: the story as it was until the new one is ready.' : ''}</span>
|
|
127
|
+
{!!writing.log.length && <ol class="story-log">{writing.log.map((l) => <li class={l.kind}>{l.by ? `${l.by}: ` : 'checker: '}{l.text}</li>)}</ol>}
|
|
128
|
+
</section>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function Verdict({ status, onAsk, asking, onFirst }: { status: Status; onAsk: (step: 'voices' | 'plates') => void; asking: boolean; onFirst: () => void }) {
|
|
133
|
+
const ok = status.issues.length === 0;
|
|
134
|
+
return (
|
|
135
|
+
<section class="verdict">
|
|
136
|
+
<div class="row">
|
|
137
|
+
<span class={`badge ${ok ? 'ok' : 'fail'}`}>{ok ? 'winnable' : `${status.issues.length} problem${status.issues.length === 1 ? '' : 's'}`}</span>
|
|
138
|
+
<span class="detail">{status.states.toLocaleString()} states played · {status.routes.map((r) => `${r.ending} in ${r.steps.length} clicks`).join(' · ')}</span>
|
|
139
|
+
</div>
|
|
140
|
+
{status.fixes.wish && (
|
|
141
|
+
<div class="row">
|
|
142
|
+
<span class="detail">Rewritten by the agent to your wish: “{status.fixes.wish}”</span>
|
|
143
|
+
<span class="grow" />
|
|
144
|
+
<button type="button" class="ghost small" title="Drop the wish and go back to Spark's first story (kept, so free). Reworded lines go too" onClick={onFirst}>Back to the first story</button>
|
|
145
|
+
</div>
|
|
146
|
+
)}
|
|
147
|
+
{!ok && <ul class="issues">{status.issues.map((i) => <li>{i}</li>)}</ul>}
|
|
148
|
+
{(status.unvoiced.length > 0 || status.unpainted.length > 0) && (
|
|
149
|
+
<div class="row">
|
|
150
|
+
{status.unvoiced.length > 0 && <button type="button" class="ghost small" disabled={asking} title={`Ask the agent to review missing voices and cost before you approve generation:\n${status.unvoiced.join('\n')}`} onClick={() => onAsk('voices')}>Ask agent about {status.unvoiced.length} missing voice{status.unvoiced.length === 1 ? '' : 's'}</button>}
|
|
151
|
+
{status.unpainted.length > 0 && <button type="button" class="ghost small" disabled={asking} title="Ask the agent to review missing paintings and cost before you approve generation" onClick={() => onAsk('plates')}>Ask agent about {status.unpainted.length} unpainted change{status.unpainted.length === 1 ? '' : 's'}</button>}
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
{(status.unseen?.length ?? 0) > 0 && <details open><summary class="detail">{status.unseen!.length} part{status.unseen!.length === 1 ? '' : 's'} of the story the painting will not show (ask the agent to redo)</summary><ul class="issues">{status.unseen!.map((l) => <li>{l}</li>)}</ul></details>}
|
|
155
|
+
{(status.unanswerable?.length ?? 0) > 0 && <details open><summary class="detail">{status.unanswerable!.length} question{status.unanswerable!.length === 1 ? '' : 's'} the player cannot answer (ask the agent to reword)</summary><ul class="issues">{status.unanswerable!.map((l) => <li>{l}</li>)}</ul></details>}
|
|
156
|
+
{status.long.length > 0 && <details><summary class="detail">{status.long.length} line{status.long.length === 1 ? ' is' : 's are'} over the word limit</summary><ul class="issues">{status.long.map((l) => <li>{l}</li>)}</ul></details>}
|
|
157
|
+
</section>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const COL = 280;
|
|
162
|
+
const ROW = 100;
|
|
163
|
+
const NODE = { w: 210, h: 72 };
|
|
164
|
+
|
|
165
|
+
/** Keep labels at reading size; a neighbourhood can be laid out independently of the whole world. */
|
|
166
|
+
export function StoryMap({ status, story, pick, onPick }: { status: Status; story: Story; pick: string | null; onPick: (key: string) => void }) {
|
|
167
|
+
const [zoom, setZoom] = useState(1);
|
|
168
|
+
const [nearby, setNearby] = useState(false);
|
|
169
|
+
const viewport = useRef<HTMLDivElement>(null);
|
|
170
|
+
useEffect(() => { if (!pick) setNearby(false); }, [pick]);
|
|
171
|
+
const graph = useMemo(() => {
|
|
172
|
+
const keys = new Set(status.places.map((p) => p.key));
|
|
173
|
+
const joins: { a: string; b: string; locked: string[]; ending?: boolean }[] = [];
|
|
174
|
+
for (const p of status.places) for (const e of p.exits) {
|
|
175
|
+
if (!keys.has(e.to)) continue;
|
|
176
|
+
const lock = story.locks.find((l) => l.place === p.key && l.via === e.via);
|
|
177
|
+
const known = joins.find((j) => (j.a === e.to && j.b === p.key) || (j.a === p.key && j.b === e.to));
|
|
178
|
+
const conditions = lock ? [`${p.key} → ${e.to}: ${lock.until.map(fact).join(', ')}`] : [];
|
|
179
|
+
if (known) known.locked.push(...conditions);
|
|
180
|
+
else joins.push({ a: p.key, b: e.to, locked: conditions });
|
|
181
|
+
}
|
|
182
|
+
const addEnding = (from: string, end: string) => {
|
|
183
|
+
const to = `ending:${end}`;
|
|
184
|
+
if (keys.has(from) && story.endings.some((e) => e.key === end) && !joins.some((j) => j.a === from && j.b === to)) {
|
|
185
|
+
joins.push({ a: from, b: to, locked: [], ending: true });
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
for (const it of story.interactions) for (const d of it.do) if (d.ending) addEnding(it.place, d.ending);
|
|
189
|
+
for (const dl of story.dialogues) for (const l of dl.lines) for (const c of l.choices) for (const d of c.do) if (d.ending) addEnding(dl.place, d.ending);
|
|
190
|
+
return joins;
|
|
191
|
+
}, [status, story]);
|
|
192
|
+
const layout = useMemo(() => {
|
|
193
|
+
const visible = nearby && pick ? new Set([pick, ...graph.filter((j) => j.a === pick || j.b === pick).flatMap((j) => [j.a, j.b])]) : null;
|
|
194
|
+
const places = status.places.filter((p) => !visible || visible.has(p.key));
|
|
195
|
+
const endings = story.endings.filter((e) => !visible || visible.has(`ending:${e.key}`));
|
|
196
|
+
const depth = new Map<string, number>([[status.start, 0]]);
|
|
197
|
+
const queue = [status.start];
|
|
198
|
+
for (let i = 0; i < queue.length; i++) {
|
|
199
|
+
const at = queue[i];
|
|
200
|
+
for (const e of status.places.find((p) => p.key === at)?.exits ?? []) if (!depth.has(e.to)) { depth.set(e.to, depth.get(at)! + 1); queue.push(e.to); }
|
|
201
|
+
}
|
|
202
|
+
const last = Math.max(0, ...depth.values());
|
|
203
|
+
// Disconnected places share a column instead of making the map ever wider.
|
|
204
|
+
for (const p of status.places) if (!depth.has(p.key)) depth.set(p.key, last + 1);
|
|
205
|
+
const columns = [...new Set(places.map((p) => depth.get(p.key)!))].sort((a, b) => a - b);
|
|
206
|
+
const rows = new Map<number, number>();
|
|
207
|
+
const pos = new Map<string, { x: number; y: number }>();
|
|
208
|
+
for (const p of places) {
|
|
209
|
+
const col = columns.indexOf(depth.get(p.key)!);
|
|
210
|
+
const row = rows.get(col) ?? 0;
|
|
211
|
+
rows.set(col, row + 1);
|
|
212
|
+
pos.set(p.key, { x: 24 + col * COL, y: 24 + row * ROW });
|
|
213
|
+
}
|
|
214
|
+
endings.forEach((e, i) => pos.set(`ending:${e.key}`, { x: 24 + columns.length * COL, y: 24 + i * ROW }));
|
|
215
|
+
const base = 24 + Math.max(1, endings.length, ...rows.values()) * ROW;
|
|
216
|
+
let lane = 0;
|
|
217
|
+
const joins = graph.filter((j) => pos.has(j.a) && pos.has(j.b)).map((j) => {
|
|
218
|
+
let a = pos.get(j.a)!, b = pos.get(j.b)!;
|
|
219
|
+
if (a.x > b.x) [a, b] = [b, a];
|
|
220
|
+
const ay = a.y + NODE.h / 2, by = b.y + NODE.h / 2;
|
|
221
|
+
let path: string, x: number, y: number;
|
|
222
|
+
if (b.x - a.x === COL) {
|
|
223
|
+
x = (a.x + NODE.w + b.x) / 2; y = (ay + by) / 2;
|
|
224
|
+
path = `M${a.x + NODE.w} ${ay} C${x} ${ay},${x} ${by},${b.x} ${by}`;
|
|
225
|
+
} else {
|
|
226
|
+
// Route same-column, looping and skipping edges through gutters, never through cards.
|
|
227
|
+
y = base + lane++ * 24; x = (a.x + NODE.w + b.x + NODE.w) / 2 + 18;
|
|
228
|
+
path = `M${a.x + NODE.w} ${ay} H${a.x + NODE.w + 18} V${y} H${b.x + NODE.w + 30} V${by} H${b.x + NODE.w}`;
|
|
229
|
+
}
|
|
230
|
+
return { ...j, path, x, y };
|
|
231
|
+
});
|
|
232
|
+
return { places, endings, pos, joins, width: Math.max(1, columns.length + (endings.length ? 1 : 0)) * COL + 24, height: base + lane * 24 + 12 };
|
|
233
|
+
}, [status, story, graph, nearby, pick]);
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
const at = pick && layout.pos.get(pick);
|
|
236
|
+
if (at && viewport.current) viewport.current.scrollTo({ left: Math.max(0, (at.x + NODE.w / 2) * zoom - viewport.current.clientWidth / 2), top: Math.max(0, (at.y + NODE.h / 2) * zoom - viewport.current.clientHeight / 2) });
|
|
237
|
+
}, [pick, nearby, zoom, layout]);
|
|
238
|
+
const related = new Set(pick ? [pick, ...graph.filter((j) => j.a === pick || j.b === pick).flatMap((j) => [j.a, j.b])] : []);
|
|
239
|
+
const nodes = [
|
|
240
|
+
...layout.places.map((p) => {
|
|
241
|
+
const npcs = p.things.filter((t) => t.role === 'npc');
|
|
242
|
+
const items = p.things.filter((t) => t.role === 'item');
|
|
243
|
+
return { key: p.key, title: p.key, sub: [p.key === status.start && 'start', npcs.length && `${npcs.length} character${npcs.length === 1 ? '' : 's'}`, items.length && `${items.length} item${items.length === 1 ? '' : 's'}`].filter(Boolean).join(' · ') || 'no characters or items', detail: npcs.map((t) => t.key).join(', '), ending: false };
|
|
244
|
+
}),
|
|
245
|
+
...layout.endings.map((e) => ({ key: `ending:${e.key}`, title: e.title, sub: 'ending', detail: '', ending: true })),
|
|
246
|
+
];
|
|
247
|
+
return (
|
|
248
|
+
<section class="story-map" aria-label="Places, exits and endings">
|
|
249
|
+
<div class="row map-tools">
|
|
250
|
+
<label>Find a place or ending <select value={pick ?? ''} onChange={(e) => onPick(e.currentTarget.value)}>
|
|
251
|
+
<option value="" disabled>Choose…</option>
|
|
252
|
+
{status.places.map((p) => <option key={p.key} value={p.key}>{p.key}</option>)}
|
|
253
|
+
{story.endings.map((e) => <option key={e.key} value={`ending:${e.key}`}>{e.title} · ending</option>)}
|
|
254
|
+
</select></label>
|
|
255
|
+
<button type="button" class="small" disabled={!pick} aria-pressed={nearby} onClick={() => setNearby(!nearby)}>{nearby ? 'Show whole map' : 'Show neighbourhood'}</button>
|
|
256
|
+
<span class="grow" />
|
|
257
|
+
<button type="button" class="small" aria-label="Zoom out" disabled={zoom <= 0.25} onClick={() => setZoom(Math.max(0.25, zoom - 0.25))}>−</button>
|
|
258
|
+
<button type="button" class="small" title="Reset to reading size" onClick={() => setZoom(1)}>{Math.round(zoom * 100)}%</button>
|
|
259
|
+
<button type="button" class="small" aria-label="Zoom in" disabled={zoom >= 2} onClick={() => setZoom(Math.min(2, zoom + 0.25))}>+</button>
|
|
260
|
+
<button type="button" class="small" onClick={() => setZoom(Math.min(1, (viewport.current?.clientWidth ?? layout.width) / layout.width, (viewport.current?.clientHeight ?? layout.height) / layout.height))}>Fit map</button>
|
|
261
|
+
</div>
|
|
262
|
+
<div class="map" ref={viewport} tabIndex={0} role="region" aria-label="Scrollable story map">
|
|
263
|
+
<svg width={layout.width * zoom} height={layout.height * zoom} viewBox={`0 0 ${layout.width} ${layout.height}`}>
|
|
264
|
+
{layout.joins.map((j) => (
|
|
265
|
+
<g key={`${j.a}>${j.b}`} class={`join ${j.locked.length ? 'locked' : ''} ${j.ending ? 'to-end' : ''} ${pick && j.a !== pick && j.b !== pick ? 'dim' : ''}`}>
|
|
266
|
+
<title>{j.locked.length ? j.locked.join('\n') : `${j.a} — ${j.b}`}</title>
|
|
267
|
+
<path d={j.path} />
|
|
268
|
+
{j.locked.length > 0 && <g class="lock-label"><rect x={j.x - 24} y={j.y - 10} width="48" height="20" rx="10" /><text x={j.x} y={j.y + 4} text-anchor="middle">locked</text></g>}
|
|
269
|
+
</g>
|
|
270
|
+
))}
|
|
271
|
+
{nodes.map((n) => {
|
|
272
|
+
const at = layout.pos.get(n.key)!;
|
|
273
|
+
return (
|
|
274
|
+
<g key={n.key} class={`node ${n.ending ? 'ending' : ''} ${pick === n.key ? 'on' : ''} ${n.key === status.start ? 'start' : ''} ${pick && !related.has(n.key) ? 'dim' : ''}`} transform={`translate(${at.x} ${at.y})`} tabIndex={0} role="button" aria-label={`${n.ending ? 'Ending' : 'Place'} ${n.title}`} aria-pressed={pick === n.key}
|
|
275
|
+
onClick={() => onPick(n.key)} onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), onPick(n.key))}>
|
|
276
|
+
<title>{[n.title, n.sub, n.detail].filter(Boolean).join('\n')}</title>
|
|
277
|
+
<rect width={NODE.w} height={NODE.h} rx={n.ending ? 28 : 10} />
|
|
278
|
+
<foreignObject x="14" y="12" width={NODE.w - 28} height={NODE.h - 20}>
|
|
279
|
+
<div class="map-name">{n.title}</div><div class="map-sub">{n.sub}</div>
|
|
280
|
+
</foreignObject>
|
|
281
|
+
</g>
|
|
282
|
+
);
|
|
283
|
+
})}
|
|
284
|
+
</svg>
|
|
285
|
+
</div>
|
|
286
|
+
<p class="detail map-help">{layout.places.length} of {status.places.length} places · {layout.endings.length} endings. Select a place for its lines and lock conditions. Dotted lines lead to endings.</p>
|
|
287
|
+
</section>
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** A line of the story, saved when it loses focus; the word count shows once it runs over the limit. */
|
|
292
|
+
function Text({ path, value, limit, onSave, label }: { path: string; value: string; limit?: number; onSave: (path: string, text: string) => void; label: string }) {
|
|
293
|
+
const [text, setText] = useState(value);
|
|
294
|
+
useEffect(() => setText(value), [value]);
|
|
295
|
+
const n = words(text);
|
|
296
|
+
return (
|
|
297
|
+
<span class="line">
|
|
298
|
+
<textarea id={`story-${path}`} rows={Math.min(4, Math.max(1, Math.ceil(text.length / 70)))} value={text} aria-label={label} title="Reword it here; saved when you click away. Free (a spoken line needs speaking again)"
|
|
299
|
+
onInput={(e) => setText(e.currentTarget.value)}
|
|
300
|
+
onBlur={() => text.trim() && text.trim() !== value && onSave(path, text.trim())} />
|
|
301
|
+
{limit !== undefined && n > limit && <span class="over">{n} words, keep to {limit}</span>}
|
|
302
|
+
</span>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const Facts = ({ when, lead = 'if' }: { when: string[]; lead?: string }) => (when.length ? <span class="facts">{lead} {when.map(fact).join(', ')}</span> : null);
|
|
307
|
+
const Effects = ({ effects }: { effects: Effect[] }) => (effects.length ? <span class="effects">→ {effects.map(effect).join(', ')}</span> : null);
|
|
308
|
+
|
|
309
|
+
function PlaceStory({ place, story, hero, unpainted, onSave }: { place: Place; story: Story; hero: string; unpainted: number[]; onSave: (path: string, text: string) => void }) {
|
|
310
|
+
const locks = story.locks.map((l, i) => ({ ...l, i })).filter((l) => l.place === place.key);
|
|
311
|
+
const acts = story.interactions.map((it, i) => ({ ...it, i })).filter((it) => it.place === place.key);
|
|
312
|
+
const talks = story.dialogues.map((d, i) => ({ ...d, i })).filter((d) => d.place === place.key);
|
|
313
|
+
const changes = story.changes.map((c, i) => ({ ...c, i })).filter((c) => c.place === place.key);
|
|
314
|
+
const byThing = [...new Set(acts.map((a) => a.thing))];
|
|
315
|
+
return (
|
|
316
|
+
<section class="inspect story-place">
|
|
317
|
+
<h2>{place.key}</h2>
|
|
318
|
+
<p class="detail">{place.shows}</p>
|
|
319
|
+
{(story.patrols ?? []).filter((p) => p.place === place.key).map((p) => (
|
|
320
|
+
<div class="beat"><strong>{p.npc} walks</strong> <span>{p.via.join(' → ')} → repeat</span> <Facts when={p.when} /> <span class="detail">pauses {p.pause}s at each stop</span></div>
|
|
321
|
+
))}
|
|
322
|
+
{locks.map((l) => (
|
|
323
|
+
<div class="beat">
|
|
324
|
+
<div class="row"><strong>{l.via}</strong><span class="badge">locked</span><Facts when={l.until} lead="opens when" /></div>
|
|
325
|
+
<Text path={`locks.${l.i}.say`} value={l.say} limit={LIMIT.say} onSave={onSave} label={`What ${hero} says at the locked ${l.via}`} />
|
|
326
|
+
</div>
|
|
327
|
+
))}
|
|
328
|
+
{byThing.map((thing) => (
|
|
329
|
+
<div class="beat">
|
|
330
|
+
<strong>{thing}</strong>
|
|
331
|
+
{acts.filter((a) => a.thing === thing).map((a) => (
|
|
332
|
+
<div class="act">
|
|
333
|
+
<div class="row"><span class="verb">{a.verb}{a.with ? ` with ${a.with}` : ''}</span><Facts when={a.when} />{a.once && <span class="detail">once</span>}<Effects effects={a.do} /></div>
|
|
334
|
+
<Text path={`interactions.${a.i}.say`} value={a.say} limit={LIMIT.say} onSave={onSave} label={`What ${hero} says on ${a.verb} ${thing}`} />
|
|
335
|
+
</div>
|
|
336
|
+
))}
|
|
337
|
+
</div>
|
|
338
|
+
))}
|
|
339
|
+
{talks.map((d) => (
|
|
340
|
+
<div class="beat talk">
|
|
341
|
+
<div class="row"><strong>Talking to {d.npc}</strong><Facts when={d.when} /></div>
|
|
342
|
+
{d.lines.map((l, li) => (
|
|
343
|
+
<div class={`act ${l.key === d.start ? 'first' : ''}`}>
|
|
344
|
+
<span class="verb">{l.key}{l.key === d.start ? ' · first' : ''}</span>
|
|
345
|
+
<Text path={`dialogues.${d.i}.lines.${li}.says`} value={l.says} limit={LIMIT.says} onSave={onSave} label={`${d.npc} says (${l.key})`} />
|
|
346
|
+
<ul class="choices">
|
|
347
|
+
{l.choices.map((c, ci) => (
|
|
348
|
+
<li>
|
|
349
|
+
<Text path={`dialogues.${d.i}.lines.${li}.choices.${ci}.text`} value={c.text} limit={LIMIT.choice} onSave={onSave} label={`${hero} answers`} />
|
|
350
|
+
<span class="row"><Facts when={c.when} /><Effects effects={c.do} /><span class="detail">{c.next ? `then ${c.next}` : 'ends the talk'}</span></span>
|
|
351
|
+
</li>
|
|
352
|
+
))}
|
|
353
|
+
</ul>
|
|
354
|
+
</div>
|
|
355
|
+
))}
|
|
356
|
+
</div>
|
|
357
|
+
))}
|
|
358
|
+
{changes.map((c) => (
|
|
359
|
+
<div class="beat">
|
|
360
|
+
<div class="row"><strong>{c.thing} changes</strong><Facts when={c.when} />{unpainted.includes(c.i) && <span class="badge">not painted yet</span>}</div>
|
|
361
|
+
<Text path={`changes.${c.i}.looks`} value={c.looks} onSave={onSave} label={`How ${c.thing} looks once changed`} />
|
|
362
|
+
</div>
|
|
363
|
+
))}
|
|
364
|
+
{!locks.length && !acts.length && !talks.length && !changes.length && <p class="detail">Nothing happens here: it is a place to pass through.</p>}
|
|
365
|
+
</section>
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function EndingStory({ story, ending, route, onSave }: { story: Story; ending: string; route: string[] | null; onSave: (path: string, text: string) => void }) {
|
|
370
|
+
const i = story.endings.findIndex((e) => e.key === ending);
|
|
371
|
+
const e = story.endings[i];
|
|
372
|
+
return (
|
|
373
|
+
<section class="inspect story-place">
|
|
374
|
+
<h2>{e.title}</h2>
|
|
375
|
+
<Text path={`endings.${i}.title`} value={e.title} onSave={onSave} label="Ending title" />
|
|
376
|
+
<Text path={`endings.${i}.text`} value={e.text} limit={LIMIT.ending} onSave={onSave} label="What the narrator says at the end" />
|
|
377
|
+
{route ? <><p class="detail">Shortest way there, {route.length} clicks:</p><ol class="route">{route.map((s) => <li>{s}</li>)}</ol></> : <p class="error">No way to reach it.</p>}
|
|
378
|
+
</section>
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function Overview({ story, onSave }: { story: Story; onSave: (path: string, text: string) => void }) {
|
|
383
|
+
return (
|
|
384
|
+
<section class="inspect story-place">
|
|
385
|
+
<h2>Opening</h2>
|
|
386
|
+
<Text path="intro" value={story.intro} limit={LIMIT.intro} onSave={onSave} label="The intro the narrator reads" />
|
|
387
|
+
{story.items.length > 0 && <>
|
|
388
|
+
<strong>Items</strong>
|
|
389
|
+
<ul class="items">{story.items.map((it, i) => <li><span class="verb">{it.key}</span><Text path={`items.${i}.name`} value={it.name} onSave={onSave} label={`Name of ${it.key}`} /></li>)}</ul>
|
|
390
|
+
</>}
|
|
391
|
+
<p class="detail">Pick a place or an ending on the map to read and reword what happens there. To change what happens (a puzzle, a lock, an ending), ask the agent in the panel: it rewrites the story and the checker plays it through.</p>
|
|
392
|
+
</section>
|
|
393
|
+
);
|
|
394
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// The world's look: the style it is painted in, and style_set to change it for the whole world, one place or the
|
|
2
|
+
// cast. It asks for the estimate first (what is repainted, the calls, the dollars), then runs it as one job.
|
|
3
|
+
import { useEffect, useState } from 'preact/hooks';
|
|
4
|
+
import { call } from './api.ts';
|
|
5
|
+
|
|
6
|
+
type Estimate = { estimate: { places: string[]; cast: string[]; models: string[]; sprites: string[] }; line: string; was: string; note?: string };
|
|
7
|
+
type Styles = { style: string; kids: boolean; places: Record<string, string | null>; cast: Record<string, string | null> };
|
|
8
|
+
|
|
9
|
+
const PRESETS: Array<[string, string]> = [
|
|
10
|
+
['Realistic painterly', 'realistic hand-painted oil painting, natural proportions, soft natural light and long gentle shadows, rich but muted palette, visible brushwork, no outlines'],
|
|
11
|
+
['Watercolour', 'loose watercolour with ink lines, soft washes and paper texture, light airy palette, gentle daylight'],
|
|
12
|
+
['Gouache storybook', 'matte gouache storybook illustration, simple rounded shapes, warm limited palette, soft even light'],
|
|
13
|
+
['Cartoon', 'bright flat cartoon with thick dark outlines, bold simple shapes, saturated colours, even sunny light'],
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
/** The world's style in its own drawer: what it is painted in now beside a picture of it, the presets as cards, a wide
|
|
17
|
+
* field for your own words, what to repaint, and the cost before anything runs. */
|
|
18
|
+
export function StyleView({ slug, places, version, sample, onError, onDone }: { slug: string; places: string[]; version: number; sample?: string; onError: (m: string) => void; onDone: () => void }) {
|
|
19
|
+
const [now, setNow] = useState<Styles | null>(null);
|
|
20
|
+
const [style, setStyle] = useState('');
|
|
21
|
+
const [scope, setScope] = useState('world');
|
|
22
|
+
const [est, setEst] = useState<Estimate | null>(null);
|
|
23
|
+
const [busy, setBusy] = useState(false);
|
|
24
|
+
useEffect(() => void call<Styles>('style_get', { slug }).then((s) => (setNow(s), setStyle((v) => v || s.style))).catch((e) => onError(e.message)), [slug, version]);
|
|
25
|
+
// A new style or scope needs a new estimate before it runs.
|
|
26
|
+
useEffect(() => setEst(null), [style, scope]);
|
|
27
|
+
const input = () => {
|
|
28
|
+
const [kind, key] = scope.split(':');
|
|
29
|
+
return { slug, style: style.trim(), scope: kind, ...(key && { key }) };
|
|
30
|
+
};
|
|
31
|
+
const ask = async (go: boolean) => {
|
|
32
|
+
setBusy(true);
|
|
33
|
+
try {
|
|
34
|
+
const r = await call<Estimate>('style_set', { ...input(), go });
|
|
35
|
+
if (go) (setEst(null), onDone());
|
|
36
|
+
else setEst(r);
|
|
37
|
+
} catch (e) {
|
|
38
|
+
onError((e as Error).message);
|
|
39
|
+
} finally {
|
|
40
|
+
setBusy(false);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const cast = Object.keys(now?.cast ?? {});
|
|
44
|
+
return (
|
|
45
|
+
<div class="style-view">
|
|
46
|
+
<section class="sv-now">
|
|
47
|
+
{sample && <img src={sample} alt="" data-lightbox={sample} title="Click: large" />}
|
|
48
|
+
<div>
|
|
49
|
+
<p class="menu-head">Painted in now</p>
|
|
50
|
+
<p>{now?.style ?? 'Reading the style…'}</p>
|
|
51
|
+
{now?.kids && <p class="detail">Made from children's drawings: they stay the reference, so the children still know their things.</p>}
|
|
52
|
+
</div>
|
|
53
|
+
</section>
|
|
54
|
+
<p class="menu-head">A new style</p>
|
|
55
|
+
<div class="sv-presets">
|
|
56
|
+
{PRESETS.map(([name, text]) => (
|
|
57
|
+
<button key={name} type="button" class={`sv-card${style === text ? ' on' : ''}`} title="Use this style: you can still change the words below" onClick={() => setStyle(text)}>
|
|
58
|
+
<strong>{name}</strong>
|
|
59
|
+
<span>{text}</span>
|
|
60
|
+
</button>
|
|
61
|
+
))}
|
|
62
|
+
</div>
|
|
63
|
+
<label class="sv-words">Or in your own words (medium, palette, light; no artist names)
|
|
64
|
+
<textarea rows={3} value={style} aria-label="New style" onInput={(e) => setStyle(e.currentTarget.value)} />
|
|
65
|
+
</label>
|
|
66
|
+
<div class="row wrap">
|
|
67
|
+
<label>Repaint <select value={scope} aria-label="What to restyle" title="The whole world, one place, or the characters" onChange={(e) => setScope(e.currentTarget.value)}>
|
|
68
|
+
<option value="world">the whole world</option>
|
|
69
|
+
{places.map((p) => <option key={p} value={`place:${p}`}>place: {p}</option>)}
|
|
70
|
+
<option value="cast">all characters</option>
|
|
71
|
+
{cast.map((k) => <option key={k} value={`cast:${k}`}>character: {k}</option>)}
|
|
72
|
+
</select></label>
|
|
73
|
+
<span class="grow" />
|
|
74
|
+
{est
|
|
75
|
+
? <button type="button" class="primary" disabled={busy} title={`Repaint now: ${est.line}. History takes it back`} onClick={() => ask(true)}>Restyle</button>
|
|
76
|
+
: <button type="button" class="ghost" disabled={busy || style.trim().length < 10 || style.trim() === now?.style} title="What it repaints and about what it costs. Free" onClick={() => ask(false)}>{busy ? 'Counting…' : 'What it costs'}</button>}
|
|
77
|
+
</div>
|
|
78
|
+
{est && <p class="sv-est">{est.line}{est.note ? ` · ${est.note}` : ''}. Everything keeps its place; History takes it back.</p>}
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|