8eda27f241
PR #9 已完成首页和 layout 的视觉品牌迁移,此 commit 补齐剩余的 技术性改名 —— workspace 包名、source import、localStorage 键、 CSS keyframe、内部 header logo、.env.example、README。 - @yume/* → @infiplot/* (6 package.json + 17 imports + lockfile) - localStorage/sessionStorage: yume:* → infiplot:* (含 PR #9 新增的 yume:hintClosed) - CSS keyframe yume-ripple → infiplot-ripple - new/play 页面 header logo "云梦" → "InfiPlot" - 代码注释中的「云梦」style 形容词删除(layout.tsx, page.tsx) - 根 package.json name + description(描述跟齐 staging "AI 实时交互剧情游戏") - README: tagline / Vercel deploy URL / 目录树 / engine 描述 保留:prompts.ts 的 LLM 体裁术语「视觉小说/galgame」、CustomForm placeholder 的「视觉小说画风」(图像模型识别的风格名词)。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
977 lines
35 KiB
TypeScript
977 lines
35 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { useRouter, useSearchParams } from "next/navigation";
|
||
import {
|
||
Suspense,
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from "react";
|
||
import { PlayCanvas, type Phase } from "@/components/PlayCanvas";
|
||
import { PRESETS } from "@/lib/presets";
|
||
import type {
|
||
Beat,
|
||
BeatAudio,
|
||
BeatAudioResponse,
|
||
BeatChoice,
|
||
InsertBeatResponse,
|
||
Scene,
|
||
SceneExit,
|
||
SceneResponse,
|
||
Session,
|
||
StartResponse,
|
||
VisionResponse,
|
||
} from "@infiplot/types";
|
||
|
||
const MUTED_STORAGE_KEY = "infiplot:muted";
|
||
|
||
// Cap how long we wait for the browser to download + decode a scene image
|
||
// before giving up and rendering anyway. Runware's CDN is normally <2s for a
|
||
// 1792×1024 PNG; tolerate up to 8s before the typewriter starts so a slow
|
||
// download can't strand the player on a blank screen forever.
|
||
const IMAGE_PRELOAD_TIMEOUT_MS = 8000;
|
||
|
||
// ──────────────────────────────────────────────────────────────────────
|
||
// Image preload — decode the Runware URL in memory before committing to
|
||
// React state, so when the <img> mounts, the browser cache is warm and
|
||
// rendering is instant. Without this the user sees a blank canvas during
|
||
// the Runware-CDN download (~1-3s) after /api/scene returns.
|
||
//
|
||
// Data URIs (MOCK_IMAGE mode) and prefetched-then-cached real URLs both
|
||
// resolve fast / instantly. Errors and timeouts resolve quietly — better
|
||
// to render a broken-image than to hang the play loop indefinitely.
|
||
// ──────────────────────────────────────────────────────────────────────
|
||
|
||
function preloadImage(url: string): Promise<void> {
|
||
return new Promise<void>((resolve) => {
|
||
const img = new Image();
|
||
const done = () => resolve();
|
||
const timer = setTimeout(done, IMAGE_PRELOAD_TIMEOUT_MS);
|
||
img.onload = () => {
|
||
clearTimeout(timer);
|
||
// .decode() forces the bitmap to be fully decoded before we proceed —
|
||
// without it, a slow decode could still cause a flash on first paint.
|
||
img.decode().then(done, done);
|
||
};
|
||
img.onerror = () => {
|
||
clearTimeout(timer);
|
||
done();
|
||
};
|
||
img.src = url;
|
||
});
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────────────────
|
||
// 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;
|
||
|
||
// Warm the browser's HTTP + image-decode cache for this URL so when the
|
||
// player eventually picks this choice and we render the <img>, it's
|
||
// instant. Don't await — let the bytes stream in the background; the
|
||
// transition path will await its own preloadImage() before committing.
|
||
void preloadImage(data.imageUrl);
|
||
|
||
// 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,
|
||
},
|
||
};
|
||
// Carry forward the registry that the parent prefetch result already
|
||
// settled (it may include characters introduced by the intermediate
|
||
// scene). Without this, the L2+ prefetch starts from the original
|
||
// base.characters and a later transition through this survivor would
|
||
// silently drop voices the player has already heard.
|
||
const carriedBase: Session = {
|
||
...baseSession,
|
||
characters: data.characters,
|
||
};
|
||
prefetchScenePath(pool, carriedBase, [...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 [imageUrl, setImageUrl] = useState<string | null>(null);
|
||
const [beatAudioMap, setBeatAudioMap] = useState<Record<string, BeatAudio>>({});
|
||
// Lazy-initialize from localStorage so PlayCanvas never mounts with the
|
||
// wrong muted value (an effect-based read would briefly let audio play
|
||
// before the preference settled in a scenario where audio arrives early).
|
||
const [muted, setMuted] = useState<boolean>(() => {
|
||
if (typeof window === "undefined") return false;
|
||
try {
|
||
return window.localStorage.getItem(MUTED_STORAGE_KEY) === "1";
|
||
} catch {
|
||
return false;
|
||
}
|
||
});
|
||
const [pendingClick, setPendingClick] = useState<{
|
||
x: number;
|
||
y: number;
|
||
} | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [presentation, setPresentation] = useState(false);
|
||
const [lastExitLabel, setLastExitLabel] = useState<string | null>(null);
|
||
|
||
const startedRef = useRef(false);
|
||
const poolRef = useRef<Map<string, PrefetchEntry>>(new Map());
|
||
// Lazy per-beat audio fetches keyed by beat.id. Aborted when the scene
|
||
// changes so stale in-flight requests can't poison the new scene's map
|
||
// (beat ids like "b1" are scene-local and would collide across scenes).
|
||
const beatAudioAbortRef = useRef<Map<string, AbortController>>(new Map());
|
||
// User-toggled "语音配音" from the homepage. Defaults to true for back-compat
|
||
// when older sessionStorage payloads omit the field. Mutated once in
|
||
// bootstrap and read by fetchBeatAudio to early-return without any /api call.
|
||
const audioEnabledRef = useRef<boolean>(true);
|
||
// Mirrors `muted` so the closure-stable fetchBeatAudio (deps []) can gate on
|
||
// it. Muting stops TTS *synthesis*, not just playback — TTS is the only sound
|
||
// source, so synthesizing audio the user can't hear just burns quota.
|
||
const mutedRef = useRef<boolean>(muted);
|
||
|
||
// 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]);
|
||
|
||
const currentBeatAudio = currentBeat ? beatAudioMap[currentBeat.id] : undefined;
|
||
const audioBase64 = currentBeatAudio?.base64 ?? null;
|
||
const audioMime = currentBeatAudio?.mime ?? null;
|
||
|
||
useEffect(() => {
|
||
sessionRef.current = session;
|
||
}, [session]);
|
||
useEffect(() => {
|
||
currentSceneRef.current = currentScene;
|
||
}, [currentScene]);
|
||
useEffect(() => {
|
||
currentBeatRef.current = currentBeat;
|
||
}, [currentBeat]);
|
||
useEffect(() => {
|
||
mutedRef.current = muted;
|
||
}, [muted]);
|
||
|
||
// 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]);
|
||
|
||
// ── Lazy per-beat audio fetch ────────────────────────────────────────
|
||
// Returns silently on any failure — the UI never waits for audio, so a
|
||
// null result just means that beat plays without voice.
|
||
// Sends only the speaker's voice + the line to speak — NOT the whole
|
||
// session — so the per-beat payload stays small even with many characters
|
||
// (each voice.referenceAudioBase64 is ~160KB).
|
||
const fetchBeatAudio = useCallback(
|
||
async (
|
||
sess: Session,
|
||
beat: { id: string; speaker?: string; line?: string; lineDelivery?: string },
|
||
): Promise<void> => {
|
||
if (!audioEnabledRef.current) return; // user toggled 语音配音 → 关闭
|
||
if (mutedRef.current) return; // 静音 → 不合成 TTS(避免无谓的调用与花费)
|
||
if (!beat.speaker || !beat.line) return;
|
||
const speaker = sess.characters.find((c) => c.name === beat.speaker);
|
||
if (!speaker?.voice) return; // not yet provisioned — server can't synth anyway
|
||
if (beatAudioAbortRef.current.has(beat.id)) return;
|
||
const abort = new AbortController();
|
||
beatAudioAbortRef.current.set(beat.id, abort);
|
||
try {
|
||
const res = await fetch("/api/beat-audio", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
beat: { id: beat.id, line: beat.line, lineDelivery: beat.lineDelivery },
|
||
voice: speaker.voice,
|
||
}),
|
||
signal: abort.signal,
|
||
});
|
||
if (!res.ok) return;
|
||
const json = (await res.json()) as BeatAudioResponse;
|
||
// Skip the state write if we've been aborted between the .ok check and
|
||
// here — beat ids are scene-local, so a late arrival from a prior
|
||
// scene would otherwise overwrite the current scene's audio under the
|
||
// same id.
|
||
if (json.audio && !abort.signal.aborted) {
|
||
setBeatAudioMap((m) => ({ ...m, [beat.id]: json.audio as BeatAudio }));
|
||
}
|
||
} catch {
|
||
// aborted or network error — silent fallback
|
||
} finally {
|
||
// Only clear the slot if it's still ours. An aborted prior fetch
|
||
// running its finally late could otherwise delete the controller of a
|
||
// new fetch that took the same beat id, leaving the new one
|
||
// unabortable on the next scene change.
|
||
if (beatAudioAbortRef.current.get(beat.id) === abort) {
|
||
beatAudioAbortRef.current.delete(beat.id);
|
||
}
|
||
}
|
||
},
|
||
[],
|
||
);
|
||
|
||
function cancelBeatAudioFetches(): void {
|
||
for (const c of beatAudioAbortRef.current.values()) c.abort();
|
||
beatAudioAbortRef.current.clear();
|
||
}
|
||
|
||
// Fire one /api/beat-audio request per speaking beat in the current scene.
|
||
// Reads refs (not props) so it stays closure-stable and can be re-run on
|
||
// un-mute as well as on scene change.
|
||
const prefetchSceneAudio = useCallback(() => {
|
||
const scene = currentSceneRef.current;
|
||
const sess = sessionRef.current;
|
||
if (!scene || !sess) return;
|
||
for (const b of scene.beats) {
|
||
if (b.speaker && b.line) void fetchBeatAudio(sess, b);
|
||
}
|
||
}, [fetchBeatAudio]);
|
||
|
||
// (Re)synthesize each time the scene changes. Cancel any in-flight requests
|
||
// from the prior scene first — beat ids are scene-local ("b1" repeats across
|
||
// scenes) so a late arrival would land under the wrong beat otherwise.
|
||
useEffect(() => {
|
||
cancelBeatAudioFetches();
|
||
setBeatAudioMap({});
|
||
prefetchSceneAudio();
|
||
}, [currentScene?.id, prefetchSceneAudio]);
|
||
|
||
// ── Mute persistence (read is via the useState lazy initializer above) ─
|
||
const toggleMuted = useCallback(() => {
|
||
setMuted((prev) => {
|
||
const next = !prev;
|
||
try {
|
||
window.localStorage.setItem(MUTED_STORAGE_KEY, next ? "1" : "0");
|
||
} catch {
|
||
// ignore
|
||
}
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
// Muting stops synthesis, not just playback: abort in-flight requests when
|
||
// muting. When un-muting, re-synthesize the current scene — fetchBeatAudio
|
||
// skips synthesis while muted, so a scene entered muted has no audio to play
|
||
// back otherwise. (Clearing the map re-synthesizes already-fetched beats on a
|
||
// mid-scene un-mute, but that's bounded to one scene and a rare toggle.)
|
||
//
|
||
// Gate on actual mute *transitions*: on mount this effect would otherwise
|
||
// fire alongside the scene effect above (both call prefetchSceneAudio),
|
||
// doubling the initial /api/beat-audio batch — the first set is dispatched
|
||
// only to be aborted mid-flight, burning TTS quota.
|
||
const prevMutedRef = useRef(muted);
|
||
useEffect(() => {
|
||
const prev = prevMutedRef.current;
|
||
prevMutedRef.current = muted;
|
||
if (prev === muted) return;
|
||
cancelBeatAudioFetches();
|
||
if (muted) return;
|
||
setBeatAudioMap({});
|
||
prefetchSceneAudio();
|
||
}, [muted, prefetchSceneAudio]);
|
||
|
||
// ── Presentation mode toggle ─────────────────────────────────────────
|
||
const togglePresentation = useCallback(async () => {
|
||
const entering = !presentation;
|
||
if (entering) {
|
||
try {
|
||
if (!document.fullscreenElement) {
|
||
await document.documentElement.requestFullscreen();
|
||
}
|
||
} catch {
|
||
// ignore — fall through to chrome-less mode anyway
|
||
}
|
||
setPresentation(true);
|
||
} else {
|
||
try {
|
||
if (document.fullscreenElement) await document.exitFullscreen();
|
||
} catch {
|
||
// ignore
|
||
}
|
||
setPresentation(false);
|
||
}
|
||
}, [presentation]);
|
||
|
||
useEffect(() => {
|
||
function onKey(e: KeyboardEvent) {
|
||
if (e.key === "f" || e.key === "F") {
|
||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||
e.preventDefault();
|
||
void togglePresentation();
|
||
} else if (e.key === "Escape" && presentation) {
|
||
setPresentation(false);
|
||
}
|
||
}
|
||
function onFullscreenChange() {
|
||
if (!document.fullscreenElement && presentation) setPresentation(false);
|
||
}
|
||
window.addEventListener("keydown", onKey);
|
||
document.addEventListener("fullscreenchange", onFullscreenChange);
|
||
return () => {
|
||
window.removeEventListener("keydown", onKey);
|
||
document.removeEventListener("fullscreenchange", onFullscreenChange);
|
||
};
|
||
}, [togglePresentation, presentation]);
|
||
|
||
// ── Bootstrap: start session ─────────────────────────────────────────
|
||
useEffect(() => {
|
||
if (startedRef.current) return;
|
||
startedRef.current = true;
|
||
|
||
let payload: { worldSetting: string; styleGuide: string } | null = null;
|
||
const presetId = params.get("preset");
|
||
|
||
if (presetId) {
|
||
const p = PRESETS.find((x) => x.id === presetId);
|
||
if (p) payload = { worldSetting: p.worldSetting, styleGuide: p.styleGuide };
|
||
} else if (params.get("custom") === "1") {
|
||
const stored = sessionStorage.getItem("infiplot:custom");
|
||
if (stored) {
|
||
try {
|
||
const parsed = JSON.parse(stored) as {
|
||
worldSetting: string;
|
||
styleGuide: string;
|
||
audioEnabled?: boolean;
|
||
};
|
||
payload = { worldSetting: parsed.worldSetting, styleGuide: parsed.styleGuide };
|
||
// default true for older payloads that omit the flag
|
||
audioEnabledRef.current = parsed.audioEnabled !== false;
|
||
} catch {
|
||
payload = null;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!payload) {
|
||
router.replace("/");
|
||
return;
|
||
}
|
||
|
||
const finalPayload = payload;
|
||
|
||
fetch("/api/start", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(finalPayload),
|
||
})
|
||
.then(async (r) => {
|
||
if (!r.ok) {
|
||
const j = (await r.json().catch(() => ({}))) as { error?: string };
|
||
throw new Error(j.error ?? r.statusText);
|
||
}
|
||
return (await r.json()) as StartResponse;
|
||
})
|
||
.then(async (data) => {
|
||
// Decode the Runware image in memory before committing to state, so
|
||
// the <img> renders instantly when it mounts (same rationale as the
|
||
// performSceneTransition path).
|
||
await preloadImage(data.imageUrl);
|
||
|
||
const initial: Session = {
|
||
id: data.sessionId,
|
||
createdAt: Date.now(),
|
||
worldSetting: finalPayload.worldSetting,
|
||
styleGuide: finalPayload.styleGuide,
|
||
history: [
|
||
{
|
||
scene: data.scene,
|
||
visitedBeatIds: [data.scene.entryBeatId],
|
||
},
|
||
],
|
||
characters: data.characters,
|
||
};
|
||
visitedBeatsRef.current = [data.scene.entryBeatId];
|
||
setSession(initial);
|
||
setCurrentScene(data.scene);
|
||
setCurrentBeatId(data.scene.entryBeatId);
|
||
setImageUrl(data.imageUrl);
|
||
// beatAudioMap is populated lazily by the per-beat fetch effect once
|
||
// currentScene becomes non-null (see fetchBeatAudio).
|
||
setPhase("ready");
|
||
})
|
||
.catch((e) => setError(String(e)));
|
||
}, [params, router]);
|
||
|
||
// ── Prefetch on scene entry: L1 + recursive L2/L3 for must-pass ──────
|
||
useEffect(() => {
|
||
const s = session;
|
||
const scene = currentScene;
|
||
if (!s || !scene) return;
|
||
|
||
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,
|
||
},
|
||
};
|
||
prefetchScenePath(poolRef.current, s, [step], 0);
|
||
}
|
||
}, [currentScene?.id, session?.id]);
|
||
|
||
// 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;
|
||
const beatAborts = beatAudioAbortRef.current;
|
||
return () => {
|
||
clearPool(pool);
|
||
for (const c of beatAborts.values()) c.abort();
|
||
beatAborts.clear();
|
||
};
|
||
}, []);
|
||
|
||
// ── Handlers ──────────────────────────────────────────────────────────
|
||
|
||
function onAdvance() {
|
||
if (phase !== "ready") return;
|
||
const beat = currentBeatRef.current;
|
||
if (!beat || beat.next.type !== "continue") return;
|
||
setCurrentBeatId(beat.next.nextBeatId);
|
||
}
|
||
|
||
async function performSceneTransition(
|
||
source: PrefetchEntry | Promise<SceneResponse>,
|
||
exit: SceneExit,
|
||
visitedForCurrent: string[],
|
||
exitLabel: string,
|
||
) {
|
||
setPhase("transitioning");
|
||
setPendingClick(null);
|
||
try {
|
||
const result = await ("promise" in source ? source.promise : source);
|
||
|
||
const base = sessionRef.current;
|
||
if (!base) throw new Error("Session lost mid-transition");
|
||
|
||
// Wait for the browser to download + decode the Runware-hosted image
|
||
// BEFORE committing it to state, so the <img> renders instantly when it
|
||
// mounts. For prefetched scenes the preloadImage call inside
|
||
// prefetchScenePath has already warmed the cache, so this resolves
|
||
// almost immediately. For cold transitions we trade an extra ~1-3s of
|
||
// "transitioning" overlay for an image-pop-in-from-blank flash.
|
||
await preloadImage(result.imageUrl);
|
||
|
||
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],
|
||
},
|
||
],
|
||
characters: result.characters,
|
||
};
|
||
visitedBeatsRef.current = [result.scene.entryBeatId];
|
||
setSession(newSession);
|
||
setCurrentScene(result.scene);
|
||
setCurrentBeatId(result.scene.entryBeatId);
|
||
setImageUrl(result.imageUrl);
|
||
// beatAudioMap reset + per-beat fetches kicked off by the scene effect.
|
||
setLastExitLabel(exitLabel);
|
||
setPhase("ready");
|
||
} catch (e) {
|
||
if ((e as { name?: string }).name === "AbortError") {
|
||
setPhase("ready");
|
||
return;
|
||
}
|
||
setError(String(e));
|
||
setPhase("ready");
|
||
}
|
||
}
|
||
|
||
function onSelectChoice(choice: BeatChoice) {
|
||
if (phase !== "ready" || !session || !currentScene) return;
|
||
|
||
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 || !imageUrl) return;
|
||
setPhase("vision-thinking");
|
||
setPendingClick(click);
|
||
|
||
try {
|
||
const visionRes = await fetch("/api/vision", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ session, prevImageUrl: imageUrl, click }),
|
||
});
|
||
if (!visionRes.ok) {
|
||
const j = (await visionRes.json().catch(() => ({}))) as {
|
||
error?: string;
|
||
};
|
||
throw new Error(j.error ?? visionRes.statusText);
|
||
}
|
||
const decision = (await visionRes.json()) as VisionResponse;
|
||
|
||
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,
|
||
freeformAction: decision.intent.freeformAction,
|
||
}),
|
||
});
|
||
if (!insertRes.ok) {
|
||
const j = (await insertRes.json().catch(() => ({}))) as {
|
||
error?: string;
|
||
};
|
||
throw new Error(j.error ?? insertRes.statusText);
|
||
}
|
||
const { partial, characters: insertChars } =
|
||
(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,
|
||
lineDelivery: partial.lineDelivery,
|
||
next: { type: "continue", nextBeatId: fromBeatId },
|
||
};
|
||
|
||
const patched: Scene = {
|
||
...currentScene,
|
||
beats: [...currentScene.beats, newBeat],
|
||
};
|
||
|
||
const nextSession: Session = {
|
||
...session,
|
||
history: session.history.map((h, i, arr) =>
|
||
i === arr.length - 1 ? { ...h, scene: patched } : h,
|
||
),
|
||
characters: insertChars,
|
||
};
|
||
setSession(nextSession);
|
||
setCurrentScene(patched);
|
||
setCurrentBeatId(newBeatId);
|
||
// Insert-beat doesn't change scene.id, so the scene effect won't
|
||
// re-fire — manually kick off the audio fetch for the new beat.
|
||
if (newBeat.speaker && newBeat.line) {
|
||
void fetchBeatAudio(nextSession, {
|
||
id: newBeatId,
|
||
speaker: newBeat.speaker,
|
||
line: newBeat.line,
|
||
lineDelivery: newBeat.lineDelivery,
|
||
});
|
||
}
|
||
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) {
|
||
setError(String(e));
|
||
setPendingClick(null);
|
||
setPhase("ready");
|
||
}
|
||
}
|
||
|
||
// ── Render ────────────────────────────────────────────────────────────
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="min-h-screen flex flex-col items-center justify-center px-8">
|
||
<div className="max-w-md text-center animate-fade-in">
|
||
<p className="text-[10px] smallcaps text-clay-500 mb-6">
|
||
出 · 了 · 点 · 状 · 况
|
||
</p>
|
||
<p className="font-serif italic text-clay-900 text-lg leading-[1.7] mb-10">
|
||
{error}
|
||
</p>
|
||
<Link
|
||
href="/"
|
||
className="text-[10px] smallcaps text-clay-700 hover:text-ember-500 transition-colors inline-flex items-center gap-3"
|
||
>
|
||
<i className="fa-solid fa-arrow-left text-[9px]" />
|
||
返 回
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (presentation) {
|
||
return (
|
||
<div className="fixed inset-0 bg-black flex items-center justify-center z-50">
|
||
<PlayCanvas
|
||
imageUrl={imageUrl}
|
||
audioBase64={audioBase64}
|
||
audioMime={audioMime}
|
||
muted={muted}
|
||
phase={phase}
|
||
beat={currentBeat}
|
||
pendingClick={pendingClick}
|
||
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">
|
||
<Link
|
||
href="/"
|
||
className="text-[10px] smallcaps text-clay-600 hover:text-clay-900 transition-colors flex items-center gap-2"
|
||
>
|
||
<i className="fa-solid fa-arrow-left text-[9px]" />
|
||
InfiPlot
|
||
</Link>
|
||
<div className="flex items-center gap-3 text-[10px] smallcaps text-clay-500 num">
|
||
<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) ?? "—"}
|
||
</span>
|
||
</div>
|
||
</header>
|
||
|
||
<main className="flex-1 flex flex-col items-center justify-center px-4 md:px-8 py-6 md:py-10">
|
||
<PlayCanvas
|
||
imageUrl={imageUrl}
|
||
audioBase64={audioBase64}
|
||
audioMime={audioMime}
|
||
muted={muted}
|
||
phase={phase}
|
||
beat={currentBeat}
|
||
pendingClick={pendingClick}
|
||
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" && lastExitLabel && (
|
||
<p className="text-[9px] smallcaps text-clay-400 animate-fade-in">
|
||
<span className="mr-2">上 · 一 · 步 ·</span>
|
||
<span className="text-clay-600">{lastExitLabel}</span>
|
||
</p>
|
||
)}
|
||
</div>
|
||
</main>
|
||
|
||
<footer className="px-5 md:px-12 pb-6 flex items-center justify-between">
|
||
<button
|
||
type="button"
|
||
onClick={() => void togglePresentation()}
|
||
className="text-[9px] smallcaps text-clay-400 hover:text-clay-700 transition-colors flex items-center gap-2"
|
||
aria-label="进入演示模式"
|
||
>
|
||
<i className="fa-solid fa-expand text-[10px]" />
|
||
F · 演 · 示
|
||
</button>
|
||
<div className="text-[9px] smallcaps text-clay-400 num">Ⅰ · Ⅰ</div>
|
||
<button
|
||
type="button"
|
||
onClick={toggleMuted}
|
||
className="text-[9px] smallcaps text-clay-400 hover:text-clay-700 transition-colors flex items-center gap-2 w-[80px] justify-end"
|
||
aria-label={muted ? "取消静音" : "静音"}
|
||
>
|
||
<i
|
||
className={`fa-solid ${muted ? "fa-volume-xmark" : "fa-volume-high"} text-[10px]`}
|
||
/>
|
||
{muted ? "静 · 音" : "有 · 声"}
|
||
</button>
|
||
</footer>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function PlayPage() {
|
||
return (
|
||
<Suspense
|
||
fallback={
|
||
<div className="min-h-screen flex items-center justify-center">
|
||
<span className="text-[10px] smallcaps text-clay-500 animate-slow-pulse">
|
||
载入中
|
||
</span>
|
||
</div>
|
||
}
|
||
>
|
||
<PlayInner />
|
||
</Suspense>
|
||
);
|
||
}
|