feat: scene/beat architecture — decouple dialogue from image generation (#2)

Replace the one-image-per-interaction model with scenes that hold multiple
dialogue beats. The image regenerates only on scene-change actions; tapping
through beats and in-scene choices are instant and zero-network.

Squashed from #2:
- feat: scene/beat architecture — decouple dialogue from image generation
- fix: harden LLM-output parsing, prefetch lifecycle, and typewriter (PR review)
- fix: dedupe beat ids; fallback narration on empty insert-beat (PR review #2)

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Zonghao Yuan
2026-05-28 15:20:12 +08:00
committed by GitHub
parent d116c2e3b5
commit d1f13d51a3
13 changed files with 1275 additions and 402 deletions
@@ -1,5 +1,5 @@
import { takeTurn } from "@yume/engine";
import type { InteractRequest } from "@yume/types";
import { requestInsertBeat } from "@yume/engine";
import type { InsertBeatRequest } from "@yume/types";
import { NextResponse } from "next/server";
import { loadEngineConfig } from "@/lib/config";
@@ -7,23 +7,23 @@ export const runtime = "nodejs";
export const maxDuration = 60;
export async function POST(req: Request) {
let body: InteractRequest;
let body: InsertBeatRequest;
try {
body = (await req.json()) as InteractRequest;
body = (await req.json()) as InsertBeatRequest;
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
if (!body.session || !body.intent) {
if (!body.session || !body.freeformAction) {
return NextResponse.json(
{ error: "session and intent are required" },
{ error: "session and freeformAction are required" },
{ status: 400 },
);
}
try {
const config = loadEngineConfig();
const result = await takeTurn(config, body);
const result = await requestInsertBeat(config, body);
return NextResponse.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
+29
View File
@@ -0,0 +1,29 @@
import { requestScene } from "@yume/engine";
import type { SceneRequest } from "@yume/types";
import { NextResponse } from "next/server";
import { loadEngineConfig } from "@/lib/config";
export const runtime = "nodejs";
export const maxDuration = 120;
export async function POST(req: Request) {
let body: SceneRequest;
try {
body = (await req.json()) as SceneRequest;
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
if (!body.session) {
return NextResponse.json({ error: "session is required" }, { status: 400 });
}
try {
const config = loadEngineConfig();
const result = await requestScene(config, body);
return NextResponse.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { visionTurn } from "@yume/engine";
import { visionDecide } from "@yume/engine";
import type { VisionRequest } from "@yume/types";
import { NextResponse } from "next/server";
import { loadEngineConfig } from "@/lib/config";
@@ -23,7 +23,7 @@ export async function POST(req: Request) {
try {
const config = loadEngineConfig();
const result = await visionTurn(config, body);
const result = await visionDecide(config, body);
return NextResponse.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
+456 -164
View File
@@ -2,39 +2,236 @@
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import {
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { PlayCanvas, type Phase } from "@/components/PlayCanvas";
import { PRESETS } from "@/lib/presets";
import type {
ClickIntent,
InteractResponse,
Beat,
BeatChoice,
InsertBeatResponse,
Scene,
SceneExit,
SceneResponse,
Session,
StartResponse,
StoryFrame,
VisionResponse,
} from "@yume/types";
// ──────────────────────────────────────────────────────────────────────
// Prefetch pool — speculative SceneResponses keyed by choice path.
//
// Key format: "C1" → reached by choosing C1 from current scene.
// "C1/C2" → after C1, then C2 (recursive must-pass prefetch).
//
// When the player picks a change-scene choice, we keep that key's
// descendants (re-rooted) and abort the rest.
// ──────────────────────────────────────────────────────────────────────
const PREFETCH_MAX_DEPTH = 3;
type PrefetchEntry = {
promise: Promise<SceneResponse>;
abort: AbortController;
};
type ScenePathStep = {
fromScene: Scene;
fromVisitedBeats: string[];
exit: { choiceId: string; label: string; nextSceneSeed: string };
};
function pathKey(steps: ScenePathStep[]): string {
return steps.map((s) => s.exit.choiceId).join("/");
}
function buildSpeculativeSession(
base: Session,
steps: ScenePathStep[],
): Session {
// Drop base's current (last) entry and re-add each step's `fromScene` with
// its exit set. Final result has `history.length = base.length - 1 + steps.length`.
const newHistory = [...base.history.slice(0, -1)];
for (const step of steps) {
newHistory.push({
scene: step.fromScene,
visitedBeatIds: step.fromVisitedBeats,
exit: {
kind: "choice",
choiceId: step.exit.choiceId,
label: step.exit.label,
nextSceneSeed: step.exit.nextSceneSeed,
},
});
}
return { ...base, history: newHistory };
}
function findAllChangeSceneChoices(scene: Scene): BeatChoice[] {
const result: BeatChoice[] = [];
const seen = new Set<string>();
for (const b of scene.beats) {
if (b.next.type === "choice") {
for (const c of b.next.choices) {
if (c.effect.kind === "change-scene" && !seen.has(c.id)) {
seen.add(c.id);
result.push(c);
}
}
}
}
return result;
}
function findSoleChangeSceneChoice(scene: Scene): BeatChoice | null {
const all = findAllChangeSceneChoices(scene);
return all.length === 1 ? all[0]! : null;
}
function prefetchScenePath(
pool: Map<string, PrefetchEntry>,
baseSession: Session,
steps: ScenePathStep[],
depth: number,
): void {
if (depth >= PREFETCH_MAX_DEPTH) return;
const key = pathKey(steps);
if (pool.has(key)) return;
const specSession = buildSpeculativeSession(baseSession, steps);
const abort = new AbortController();
const promise = (async () => {
const res = await fetch("/api/scene", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session: specSession }),
signal: abort.signal,
});
if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(j.error ?? res.statusText);
}
const data = (await res.json()) as SceneResponse;
// Recursive: if the resulting scene has exactly one change-scene exit,
// it is a must-pass node — prefetch its child too.
if (depth + 1 < PREFETCH_MAX_DEPTH) {
const sole = findSoleChangeSceneChoice(data.scene);
if (sole && sole.effect.kind === "change-scene") {
const nextStep: ScenePathStep = {
fromScene: data.scene,
fromVisitedBeats: [data.scene.entryBeatId],
exit: {
choiceId: sole.id,
label: sole.label,
nextSceneSeed: sole.effect.nextSceneSeed,
},
};
prefetchScenePath(pool, baseSession, [...steps, nextStep], depth + 1);
}
}
return data;
})();
promise.catch(() => {});
pool.set(key, { promise, abort });
}
function consumeChoice(
pool: Map<string, PrefetchEntry>,
choiceId: string,
): PrefetchEntry | undefined {
const my = pool.get(choiceId);
const survivors = new Map<string, PrefetchEntry>();
for (const [key, entry] of pool) {
if (key === choiceId) continue;
if (key.startsWith(choiceId + "/")) {
survivors.set(key.slice(choiceId.length + 1), entry);
} else {
entry.abort.abort();
}
}
pool.clear();
for (const [k, e] of survivors) pool.set(k, e);
return my;
}
function clearPool(pool: Map<string, PrefetchEntry>): void {
for (const e of pool.values()) e.abort.abort();
pool.clear();
}
// ──────────────────────────────────────────────────────────────────────
// Component
// ──────────────────────────────────────────────────────────────────────
function PlayInner() {
const router = useRouter();
const params = useSearchParams();
const [phase, setPhase] = useState<Phase>("loading-first");
const [session, setSession] = useState<Session | null>(null);
const [currentScene, setCurrentScene] = useState<Scene | null>(null);
const [currentBeatId, setCurrentBeatId] = useState<string | null>(null);
const [imageBase64, setImageBase64] = useState<string | null>(null);
const [frame, setFrame] = useState<StoryFrame | null>(null);
const [intent, setIntent] = useState<ClickIntent | null>(null);
const [pendingClick, setPendingClick] = useState<{
x: number;
y: number;
} | null>(null);
const [turnNum, setTurnNum] = useState(0);
const [error, setError] = useState<string | null>(null);
const [presentation, setPresentation] = useState(false);
const [lastExitLabel, setLastExitLabel] = useState<string | null>(null);
const startedRef = useRef(false);
const prefetchAbortRef = useRef<AbortController | null>(null);
const prefetchRef = useRef<Record<string, Promise<InteractResponse>>>({});
const poolRef = useRef<Map<string, PrefetchEntry>>(new Map());
// Mirrors for use inside async handlers (closure-stable)
const sessionRef = useRef<Session | null>(null);
const currentSceneRef = useRef<Scene | null>(null);
const currentBeatRef = useRef<Beat | null>(null);
const visitedBeatsRef = useRef<string[]>([]);
const currentBeat = useMemo<Beat | null>(() => {
if (!currentScene || !currentBeatId) return null;
return currentScene.beats.find((b) => b.id === currentBeatId) ?? null;
}, [currentScene, currentBeatId]);
useEffect(() => {
sessionRef.current = session;
}, [session]);
useEffect(() => {
currentSceneRef.current = currentScene;
}, [currentScene]);
useEffect(() => {
currentBeatRef.current = currentBeat;
}, [currentBeat]);
// Whenever currentBeatId changes, append it to visited (skip consecutive dups)
useEffect(() => {
if (!currentBeatId) return;
if (visitedBeatsRef.current.at(-1) === currentBeatId) return;
visitedBeatsRef.current = [...visitedBeatsRef.current, currentBeatId];
setSession((s) => {
if (!s) return s;
return {
...s,
history: s.history.map((h, i, arr) =>
i === arr.length - 1
? { ...h, visitedBeatIds: [...visitedBeatsRef.current] }
: h,
),
};
});
}, [currentBeatId]);
// ── Presentation mode toggle ─────────────────────────────────────────
const togglePresentation = useCallback(async () => {
const entering = !presentation;
if (entering) {
@@ -43,14 +240,12 @@ function PlayInner() {
await document.documentElement.requestFullscreen();
}
} catch {
// Browser may refuse fullscreen — still enter chrome-less mode
// ignore — fall through to chrome-less mode anyway
}
setPresentation(true);
} else {
try {
if (document.fullscreenElement) {
await document.exitFullscreen();
}
if (document.fullscreenElement) await document.exitFullscreen();
} catch {
// ignore
}
@@ -69,10 +264,7 @@ function PlayInner() {
}
}
function onFullscreenChange() {
// Sync if user exited browser fullscreen via Esc / system gesture
if (!document.fullscreenElement && presentation) {
setPresentation(false);
}
if (!document.fullscreenElement && presentation) setPresentation(false);
}
window.addEventListener("keydown", onKey);
document.addEventListener("fullscreenchange", onFullscreenChange);
@@ -82,6 +274,7 @@ function PlayInner() {
};
}, [togglePresentation, presentation]);
// ── Bootstrap: start session ─────────────────────────────────────────
useEffect(() => {
if (startedRef.current) return;
startedRef.current = true;
@@ -91,9 +284,7 @@ function PlayInner() {
if (presetId) {
const p = PRESETS.find((x) => x.id === presetId);
if (p) {
payload = { worldSetting: p.worldSetting, styleGuide: p.styleGuide };
}
if (p) payload = { worldSetting: p.worldSetting, styleGuide: p.styleGuide };
} else if (params.get("custom") === "1") {
const stored = sessionStorage.getItem("yume:custom");
if (stored) {
@@ -122,151 +313,176 @@ function PlayInner() {
const j = (await r.json().catch(() => ({}))) as { error?: string };
throw new Error(j.error ?? r.statusText);
}
return r.json() as Promise<StartResponse>;
return (await r.json()) as StartResponse;
})
.then((data) => {
setSession({
const initial: Session = {
id: data.sessionId,
createdAt: Date.now(),
worldSetting: finalPayload.worldSetting,
styleGuide: finalPayload.styleGuide,
history: [{ frame: data.frame }],
characters: [],
});
setFrame(data.frame);
history: [
{
scene: data.scene,
visitedBeatIds: [data.scene.entryBeatId],
},
],
};
visitedBeatsRef.current = [data.scene.entryBeatId];
setSession(initial);
setCurrentScene(data.scene);
setCurrentBeatId(data.scene.entryBeatId);
setImageBase64(data.imageBase64);
setPhase("ready");
setTurnNum(1);
})
.catch((e) => setError(String(e)));
}, [params, router]);
// Prefetch next-frame candidates whenever current frame becomes ready.
// All three fire in parallel for fastest cache fill. NOT depending on
// `phase` — we don't want to abort in-flight prefetches just because
// the user clicked. They should continue so handleClick can await them.
// ── Prefetch on scene entry: L1 + recursive L2/L3 for must-pass ──────
useEffect(() => {
if (!session || !frame) return;
const s = session;
const scene = currentScene;
if (!s || !scene) return;
prefetchAbortRef.current?.abort();
const ctrl = new AbortController();
prefetchAbortRef.current = ctrl;
const choices = frame.uiElements.filter((e) => e.kind === "choice");
const promises: Record<string, Promise<InteractResponse>> = {};
for (const choice of choices) {
const syntheticIntent: ClickIntent = {
targetId: choice.id,
targetLabel: choice.label,
reasoning: "prefetch",
const exits = findAllChangeSceneChoices(scene);
for (const choice of exits) {
if (choice.effect.kind !== "change-scene") continue;
const step: ScenePathStep = {
fromScene: scene,
// Snapshot of visited beats at prefetch start. Slight drift is OK.
fromVisitedBeats: [...visitedBeatsRef.current],
exit: {
choiceId: choice.id,
label: choice.label,
nextSceneSeed: choice.effect.nextSceneSeed,
},
};
const p = fetch("/api/interact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session, intent: syntheticIntent }),
signal: ctrl.signal,
}).then(async (r) => {
if (!r.ok) {
const j = (await r.json().catch(() => ({}))) as { error?: string };
throw new Error(j.error ?? r.statusText);
}
return r.json() as Promise<InteractResponse>;
});
p.catch(() => {});
promises[choice.id] = p;
prefetchScenePath(poolRef.current, s, [step], 0);
}
}, [currentScene?.id, session?.id]);
prefetchRef.current = promises;
// Abort all in-flight speculative prefetches when the page unmounts, so we
// stop paying for background scene/image generation. Empty deps → fires only
// on unmount; it must NOT run on scene transitions, which rely on
// consumeChoice keeping the re-rooted survivor prefetches alive.
useEffect(() => {
const pool = poolRef.current;
return () => {
ctrl.abort();
clearPool(pool);
};
}, [frame?.id, session?.id]);
}, []);
// ── Shared result applier ────────────────────────────────────────────
async function applyInteractResult(
resultPromise: Promise<InteractResponse>,
clickIntent: ClickIntent,
click?: { x: number; y: number },
) {
const result = await resultPromise;
// Overwrite synthetic prefetch intent with the real click intent
const lastIdx = result.session.history.length - 1;
const patched: InteractResponse = {
...result,
intent: clickIntent,
session: {
...result.session,
history: result.session.history.map((entry, idx) =>
idx === lastIdx ? { ...entry, click, intent: clickIntent } : entry,
),
},
};
const updatedHistory = [
...patched.session.history,
{ frame: patched.frame },
];
setSession({ ...patched.session, history: updatedHistory });
setFrame(patched.frame);
setImageBase64(patched.imageBase64);
setIntent(clickIntent);
setPendingClick(null);
setTurnNum((t) => t + 1);
setPhase("ready");
// ── Handlers ──────────────────────────────────────────────────────────
function onAdvance() {
if (phase !== "ready") return;
const beat = currentBeatRef.current;
if (!beat || beat.next.type !== "continue") return;
setCurrentBeatId(beat.next.nextBeatId);
}
// ── HTML button click — bypasses Vision entirely ──────────────────────
async function handleChoiceSelect(choiceId: string, label: string) {
if (phase !== "ready" || !session) return;
setPhase("interacting");
setIntent(null);
const clickIntent: ClickIntent = {
targetId: choiceId,
targetLabel: label,
reasoning: "direct-button-click",
};
const cacheSnapshot = prefetchRef.current;
const cached = cacheSnapshot[choiceId];
async function performSceneTransition(
source: PrefetchEntry | Promise<SceneResponse>,
exit: SceneExit,
visitedForCurrent: string[],
exitLabel: string,
) {
setPhase("transitioning");
setPendingClick(null);
try {
if (cached) {
// Cache hit — zero extra wait
await applyInteractResult(cached, clickIntent);
} else {
// Cache miss — call interact directly (no Vision roundtrip)
prefetchAbortRef.current?.abort();
const res = await fetch("/api/interact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session, intent: clickIntent }),
});
if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(j.error ?? res.statusText);
}
await applyInteractResult(
res.json() as Promise<InteractResponse>,
clickIntent,
);
}
const result = await ("promise" in source ? source.promise : source);
const base = sessionRef.current;
if (!base) throw new Error("Session lost mid-transition");
const closedHistory = base.history.map((h, i, arr) =>
i === arr.length - 1
? { ...h, visitedBeatIds: visitedForCurrent, exit }
: h,
);
const newSession: Session = {
...base,
history: [
...closedHistory,
{
scene: result.scene,
visitedBeatIds: [result.scene.entryBeatId],
},
],
};
visitedBeatsRef.current = [result.scene.entryBeatId];
setSession(newSession);
setCurrentScene(result.scene);
setCurrentBeatId(result.scene.entryBeatId);
setImageBase64(result.imageBase64);
setLastExitLabel(exitLabel);
setPhase("ready");
} catch (e) {
if ((e as { name?: string }).name === "AbortError") {
setPhase("ready");
return;
}
setError(String(e));
setPendingClick(null);
setPhase("ready");
}
}
// ── Background / free-form click — still uses Vision ─────────────────
async function handleClick(click: { x: number; y: number }) {
if (phase !== "ready" || !session || !imageBase64) return;
setPhase("interacting");
setPendingClick(click);
setIntent(null);
function onSelectChoice(choice: BeatChoice) {
if (phase !== "ready" || !session || !currentScene) return;
const cacheSnapshot = prefetchRef.current;
if (choice.effect.kind === "advance-beat") {
// Pure local jump. No network. No pool changes.
setCurrentBeatId(choice.effect.targetBeatId);
return;
}
const visited = [...visitedBeatsRef.current];
const exit: SceneExit = {
kind: "choice",
choiceId: choice.id,
label: choice.label,
nextSceneSeed: choice.effect.nextSceneSeed,
};
const cached = consumeChoice(poolRef.current, choice.id);
if (cached) {
void performSceneTransition(cached, exit, visited, choice.label);
return;
}
// Cold path — start a fresh fetch
const step: ScenePathStep = {
fromScene: currentScene,
fromVisitedBeats: visited,
exit: {
choiceId: choice.id,
label: choice.label,
nextSceneSeed: choice.effect.nextSceneSeed,
},
};
const specSession = buildSpeculativeSession(session, [step]);
clearPool(poolRef.current);
const promise = (async () => {
const res = await fetch("/api/scene", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session: specSession }),
});
if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(j.error ?? res.statusText);
}
return (await res.json()) as SceneResponse;
})();
void performSceneTransition(promise, exit, visited, choice.label);
}
async function onBackgroundClick(click: { x: number; y: number }) {
if (phase !== "ready" || !session || !currentScene || !imageBase64) return;
setPhase("vision-thinking");
setPendingClick(click);
try {
const visionRes = await fetch("/api/vision", {
@@ -280,32 +496,99 @@ function PlayInner() {
};
throw new Error(j.error ?? visionRes.statusText);
}
const { intent: clickIntent } =
(await visionRes.json()) as VisionResponse;
const decision = (await visionRes.json()) as VisionResponse;
const cached = clickIntent.targetId
? cacheSnapshot[clickIntent.targetId]
: undefined;
if (cached) {
await applyInteractResult(cached, clickIntent, click);
} else {
prefetchAbortRef.current?.abort();
const liveRes = await fetch("/api/interact", {
if (decision.classify === "insert-beat") {
setPhase("inserting-beat");
const insertRes = await fetch("/api/insert-beat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session, intent: clickIntent, click }),
body: JSON.stringify({
session,
freeformAction: decision.intent.freeformAction,
}),
});
if (!liveRes.ok) {
const j = (await liveRes.json().catch(() => ({}))) as {
if (!insertRes.ok) {
const j = (await insertRes.json().catch(() => ({}))) as {
error?: string;
};
throw new Error(j.error ?? liveRes.statusText);
throw new Error(j.error ?? insertRes.statusText);
}
await applyInteractResult(
liveRes.json() as Promise<InteractResponse>,
clickIntent,
click,
const { partial } = (await insertRes.json()) as InsertBeatResponse;
const fromBeatId =
currentBeatRef.current?.id ?? currentScene.entryBeatId;
const newBeatId = `b_ins_${Date.now()}_${Math.random()
.toString(36)
.slice(2, 6)}`;
const newBeat: Beat = {
id: newBeatId,
narration: partial.narration,
speaker: partial.speaker,
line: partial.line,
next: { type: "continue", nextBeatId: fromBeatId },
};
const patched: Scene = {
...currentScene,
beats: [...currentScene.beats, newBeat],
};
setSession((s) =>
s
? {
...s,
history: s.history.map((h, i, arr) =>
i === arr.length - 1 ? { ...h, scene: patched } : h,
),
}
: s,
);
setCurrentScene(patched);
setCurrentBeatId(newBeatId);
setLastExitLabel(decision.intent.freeformAction);
setPhase("ready");
setPendingClick(null);
} else {
const exit: SceneExit = {
kind: "freeform",
action: decision.intent.freeformAction,
};
const visited = [...visitedBeatsRef.current];
const base = sessionRef.current;
if (!base) {
setPhase("ready");
setPendingClick(null);
return;
}
const specSession: Session = {
...base,
history: base.history.map((h, i, arr) =>
i === arr.length - 1 ? { ...h, visitedBeatIds: visited, exit } : h,
),
};
clearPool(poolRef.current);
const promise = (async () => {
const res = await fetch("/api/scene", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session: specSession }),
});
if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as {
error?: string;
};
throw new Error(j.error ?? res.statusText);
}
return (await res.json()) as SceneResponse;
})();
await performSceneTransition(
promise,
exit,
visited,
decision.intent.freeformAction,
);
}
} catch (e) {
@@ -315,6 +598,8 @@ function PlayInner() {
}
}
// ── Render ────────────────────────────────────────────────────────────
if (error) {
return (
<div className="min-h-screen flex flex-col items-center justify-center px-8">
@@ -343,16 +628,20 @@ function PlayInner() {
<PlayCanvas
imageBase64={imageBase64}
phase={phase}
frame={frame}
beat={currentBeat}
pendingClick={pendingClick}
onClick={handleClick}
onSelectChoice={handleChoiceSelect}
onBackgroundClick={onBackgroundClick}
onAdvance={onAdvance}
onSelectChoice={onSelectChoice}
fullViewport
/>
</div>
);
}
const sceneCount = session?.history.length ?? 0;
const beatCount = visitedBeatsRef.current.length;
return (
<div className="min-h-screen flex flex-col">
<header className="px-5 md:px-12 pt-6 md:pt-8 flex items-center justify-between">
@@ -364,7 +653,9 @@ function PlayInner() {
</Link>
<div className="flex items-center gap-3 text-[10px] smallcaps text-clay-500 num">
<span> · {String(turnNum).padStart(3, "0")} · </span>
<span> · {String(sceneCount).padStart(3, "0")} · </span>
<span className="text-clay-300">·</span>
<span>{String(beatCount).padStart(3, "0")} · </span>
<span className="text-clay-300">·</span>
<span className="hidden sm:inline truncate max-w-[180px]">
{session?.id.slice(2, 14) ?? "—"}
@@ -376,22 +667,23 @@ function PlayInner() {
<PlayCanvas
imageBase64={imageBase64}
phase={phase}
frame={frame}
beat={currentBeat}
pendingClick={pendingClick}
onClick={handleClick}
onSelectChoice={handleChoiceSelect}
onBackgroundClick={onBackgroundClick}
onAdvance={onAdvance}
onSelectChoice={onSelectChoice}
/>
<div className="mt-4 max-w-md w-full text-center min-h-[28px] flex items-center justify-center">
{phase === "loading-first" && (
<p className="text-[10px] smallcaps text-clay-500 animate-slow-pulse">
· · · · · ·
· · · · · ·
</p>
)}
{phase === "ready" && intent?.targetLabel && (
{phase === "ready" && lastExitLabel && (
<p className="text-[9px] smallcaps text-clay-400 animate-fade-in">
<span className="mr-2"> · · ·</span>
<span className="text-clay-600">{intent.targetLabel}</span>
<span className="text-clay-600">{lastExitLabel}</span>
</p>
)}
</div>
+118 -78
View File
@@ -1,34 +1,70 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { StoryFrame } from "@yume/types";
import { useCallback, useEffect, useRef, useState } from "react";
import type { Beat, BeatChoice } from "@yume/types";
export type Phase = "loading-first" | "ready" | "interacting";
export type Phase =
| "loading-first" // first scene not yet rendered
| "ready" // current beat is interactive
| "vision-thinking" // background click → waiting on vision verdict
| "inserting-beat" // vision-driven beat being generated
| "transitioning"; // changing scenes (cache miss or speculative wait)
const SHADOW =
"0 1px 0 rgba(45,24,16,0.05), 0 36px 64px -28px rgba(45,24,16,0.25), 0 8px 18px -6px rgba(45,24,16,0.10)";
// ── Typewriter hook ────────────────────────────────────────────────────
function useTypewriter(text: string, speed = 28): string {
// Returns the progressively-revealed text, a `done` flag, and a `skip()` that
// instantly completes the current text. Reset is keyed by `resetKey` (the beat
// id) rather than the text, so a new beat whose line happens to match the
// previous one still replays from scratch. `done` is derived synchronously
// (not from a post-paint effect) so a stale "done" frame never paints.
function useTypewriter(
text: string,
resetKey: string,
speed = 28,
): { shown: string; done: boolean; skip: () => void } {
const [displayed, setDisplayed] = useState("");
const textRef = useRef(text);
const [prevKey, setPrevKey] = useState(resetKey);
const timer = useRef<ReturnType<typeof setInterval> | null>(null);
// Render-phase reset (React "adjust state on prop change" pattern): when the
// beat changes, drop the old progress before this render commits.
if (resetKey !== prevKey) {
setPrevKey(resetKey);
setDisplayed("");
}
useEffect(() => {
// Reset immediately when the text changes
setDisplayed("");
textRef.current = text;
if (!text) return;
let i = 0;
const id = setInterval(() => {
timer.current = setInterval(() => {
i += 1;
setDisplayed(text.slice(0, i));
if (i >= text.length) clearInterval(id);
if (i >= text.length && timer.current) {
clearInterval(timer.current);
timer.current = null;
}
}, speed);
return () => clearInterval(id);
}, [text, speed]);
return () => {
if (timer.current) clearInterval(timer.current);
timer.current = null;
};
}, [resetKey, text, speed]);
return displayed;
const skip = useCallback(() => {
if (timer.current) {
clearInterval(timer.current);
timer.current = null;
}
setDisplayed(text);
}, [text]);
// During the throwaway render where the beat just changed, `displayed` still
// holds the previous beat's text — coerce it to empty so nothing stale shows.
const shown = resetKey === prevKey ? displayed : "";
const done = text.length === 0 || shown.length >= text.length;
return { shown, done, skip };
}
// ── Choice button ──────────────────────────────────────────────────────
@@ -59,7 +95,6 @@ function ChoiceButton({
boxShadow: "0 2px 12px rgba(0,0,0,0.4), inset 0 1px 0 rgba(200,165,90,0.12)",
}}
>
{/* Hover shimmer overlay */}
<span
className="absolute inset-0 rounded-[5px] opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none"
style={{
@@ -89,49 +124,59 @@ function ChoiceButton({
export function PlayCanvas({
imageBase64,
phase,
frame,
beat,
pendingClick,
onClick,
onBackgroundClick,
onAdvance,
onSelectChoice,
fullViewport = false,
}: {
imageBase64: string | null;
phase: Phase;
frame: StoryFrame | null;
beat: Beat | null;
pendingClick: { x: number; y: number } | null;
onClick: (click: { x: number; y: number }) => void;
onSelectChoice?: (choiceId: string, label: string) => void;
onBackgroundClick: (click: { x: number; y: number }) => void;
onAdvance: () => void;
onSelectChoice: (choice: BeatChoice) => void;
fullViewport?: boolean;
}) {
const imgRef = useRef<HTMLImageElement>(null);
const [dims, setDims] = useState<{ w: number; h: number } | null>(null);
const choices = frame?.uiElements.filter((e) => e.kind === "choice") ?? [];
const dialogueText = frame
? [frame.speaker ? `${frame.speaker}${frame.line ?? ""}` : frame.line, frame.narration]
.filter(Boolean)
.join("\n")
: "";
const narrationOnly = !frame?.speaker && !frame?.line && !!frame?.narration;
const displayBody = frame?.speaker
? frame.line ?? ""
: frame?.narration ?? "";
const isChoiceBeat = beat?.next.type === "choice";
const choices: BeatChoice[] = isChoiceBeat
? (beat!.next as { type: "choice"; choices: BeatChoice[] }).choices
: [];
const typedBody = useTypewriter(displayBody, 30);
const displayBody = beat?.speaker ? beat.line ?? "" : beat?.narration ?? "";
const { shown: typedBody, done: typingDone, skip: skipTypewriter } =
useTypewriter(displayBody, beat?.id ?? "", 30);
function handleClick(e: React.MouseEvent<HTMLImageElement>) {
if (phase !== "ready" || !imgRef.current) return;
function handleImageClick(e: React.MouseEvent<HTMLImageElement>) {
if (phase !== "ready" || !imgRef.current || !beat) return;
const rect = imgRef.current.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
onClick({
// If the typewriter is still printing, a click completes it instantly
// (standard VN affordance) — the page never sees this click.
if (!typingDone) {
skipTypewriter();
return;
}
// For continue-type beats, image click advances; for choice beats,
// image click goes through vision (treat as freeform action).
if (beat.next.type === "continue") {
onAdvance();
return;
}
onBackgroundClick({
x: Math.max(0, Math.min(1, x)),
y: Math.max(0, Math.min(1, y)),
});
}
const interactive = phase === "ready" && !!imageBase64;
const dimmed = phase === "interacting";
const dimmed = phase === "transitioning";
const sizeStyle = fullViewport
? { maxWidth: "100vw", maxHeight: "100dvh" }
@@ -141,6 +186,13 @@ export function PlayCanvas({
? "min(100vw, calc(100dvh * 16 / 9))"
: "min(96vw, calc((100dvh - 200px) * 16 / 9))";
const footerHint =
phase === "ready"
? isChoiceBeat
? "选 · 择 · 一 · 项"
: "点 · 击 · 推 · 进"
: "···";
return (
<div
className={`flex flex-col items-center ${fullViewport ? "w-full h-full justify-center" : "w-full"}`}
@@ -150,13 +202,13 @@ export function PlayCanvas({
className="relative inline-block"
style={{ boxShadow: fullViewport ? "none" : SHADOW }}
>
{/* ── Background image ── */}
{/* Background image */}
<img
key={imageBase64.slice(-48)}
ref={imgRef}
src={`data:image/png;base64,${imageBase64}`}
alt="Generated frame"
onClick={handleClick}
alt="Generated scene"
onClick={handleImageClick}
onLoad={(e) => {
const img = e.currentTarget;
setDims({ w: img.naturalWidth, h: img.naturalHeight });
@@ -168,37 +220,27 @@ export function PlayCanvas({
style={sizeStyle}
/>
{/* ── Top/bottom gradient vignette ── */}
{!fullViewport && (
<>
<div className="absolute inset-x-0 top-0 h-10 bg-gradient-to-b from-clay-900/12 to-transparent pointer-events-none" />
</>
<div className="absolute inset-x-0 top-0 h-10 bg-gradient-to-b from-clay-900/12 to-transparent pointer-events-none" />
)}
{/* ══════════════════════════════════════════════════════════
PREFAB UI OVERLAY — rendered on top of image
══════════════════════════════════════════════════════════ */}
{frame && (
{beat && (
<div className="absolute inset-0 flex flex-col justify-end pointer-events-none select-none">
{/* ── Choices row ── */}
{choices.length > 0 && (
<div
className="pointer-events-auto px-[3%] pb-[1.5%] flex gap-[1.5%] items-stretch"
>
<div className="pointer-events-auto px-[3%] pb-[1.5%] flex gap-[1.5%] items-stretch">
{choices.map((choice, i) => (
<ChoiceButton
key={choice.id}
index={i}
label={choice.label}
disabled={phase !== "ready"}
onClick={() => onSelectChoice?.(choice.id, choice.label)}
onClick={() => onSelectChoice(choice)}
/>
))}
</div>
)}
{/* ── Dialogue / narration box ── */}
{(frame.narration || frame.line) && (
{(beat.narration || beat.line) && (
<div
className="pointer-events-none mx-[2%] mb-[2%] px-[3%] py-[2.2%] relative"
style={{
@@ -211,7 +253,6 @@ export function PlayCanvas({
"0 4px 24px rgba(0,0,0,0.55), inset 0 1px 0 rgba(200,165,90,0.10)",
}}
>
{/* Inner golden corner decoration */}
<span
className="absolute top-[6px] left-[8px] text-[10px] opacity-40 pointer-events-none"
style={{ color: "rgba(195,155,75,1)" }}
@@ -227,56 +268,57 @@ export function PlayCanvas({
</span>
{/* Speaker name tag */}
{frame.speaker && (
{beat.speaker && (
<p
className="font-serif text-[11px] md:text-[12px] smallcaps mb-[0.6em]"
style={{ color: "rgba(205,165,90,0.92)" }}
>
{frame.speaker}
{beat.speaker}
</p>
)}
{/* Main text */}
<p
className="font-serif leading-[1.85] text-[13px] md:text-[15px]"
style={{ color: "rgba(245,235,210,0.95)" }}
>
{typedBody}
{/* Narration only — also show secondary line */}
{frame.speaker && frame.narration && (
{beat.speaker && beat.narration && (
<span
className="block mt-[0.5em] italic text-[12px] md:text-[13px]"
className={`block mt-[0.5em] italic text-[12px] md:text-[13px] transition-opacity duration-300 ${
typingDone ? "opacity-100" : "opacity-0"
}`}
style={{ color: "rgba(200,185,155,0.78)" }}
aria-hidden={!typingDone}
>
{frame.narration}
{beat.narration}
</span>
)}
</p>
{/* Scroll hint ▼ */}
<span
className="absolute bottom-[6px] right-[10px] text-[10px] animate-slow-pulse"
style={{ color: "rgba(195,155,75,0.7)" }}
aria-hidden
>
</span>
{typingDone && beat.next.type === "continue" && (
<span
className="absolute bottom-[6px] right-[10px] text-[10px] animate-slow-pulse"
style={{ color: "rgba(195,155,75,0.7)" }}
aria-hidden
>
</span>
)}
</div>
)}
</div>
)}
{/* Loading/interacting dim overlay */}
{phase === "interacting" && (
{(phase === "transitioning" || phase === "inserting-beat") && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<p className="text-[10px] smallcaps text-cream-50/70 animate-slow-pulse">
AI · · · · · · ·
{phase === "transitioning"
? "AI · 正 · 在 · 描 · 画 · 下 · 一 · 幕"
: "AI · 正 · 在 · 想 · 你 · 看 · 到 · 了 · 什 · 么"}
</p>
</div>
)}
{/* Click ripple indicator */}
{pendingClick && (
<>
<div
@@ -317,7 +359,7 @@ export function PlayCanvas({
>
<div className="w-1.5 h-1.5 bg-clay-500 rounded-full animate-slow-pulse" />
<p className="text-[9px] smallcaps text-clay-500 animate-slow-pulse">
· · · · · ·
· · · · · ·
</p>
</div>
)}
@@ -330,9 +372,7 @@ export function PlayCanvas({
<span className="text-[9px] smallcaps text-clay-400 num">
{dims ? `${dims.w} × ${dims.h} · png` : "—"}
</span>
<span className="text-[9px] smallcaps text-clay-400">
{phase === "ready" ? (choices.length > 0 ? "选 · 择 · 一 · 项" : "任 · 意 · 点 · 击") : "···"}
</span>
<span className="text-[9px] smallcaps text-clay-400">{footerHint}</span>
</div>
)}
</div>