"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, BeatChoice, InsertBeatResponse, Scene, SceneExit, SceneResponse, Session, StartResponse, 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; 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(); 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, 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, choiceId: string, ): PrefetchEntry | undefined { const my = pool.get(choiceId); const survivors = new Map(); 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): 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("loading-first"); const [session, setSession] = useState(null); const [currentScene, setCurrentScene] = useState(null); const [currentBeatId, setCurrentBeatId] = useState(null); const [imageBase64, setImageBase64] = useState(null); const [pendingClick, setPendingClick] = useState<{ x: number; y: number; } | null>(null); const [error, setError] = useState(null); const [presentation, setPresentation] = useState(false); const [lastExitLabel, setLastExitLabel] = useState(null); const startedRef = useRef(false); const poolRef = useRef>(new Map()); // Mirrors for use inside async handlers (closure-stable) const sessionRef = useRef(null); const currentSceneRef = useRef(null); const currentBeatRef = useRef(null); const visitedBeatsRef = useRef([]); const currentBeat = useMemo(() => { 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) { 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("yume:custom"); if (stored) { try { payload = JSON.parse(stored); } 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((data) => { const initial: Session = { id: data.sessionId, createdAt: Date.now(), worldSetting: finalPayload.worldSetting, styleGuide: finalPayload.styleGuide, 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"); }) .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; return () => { clearPool(pool); }; }, []); // ── 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, 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"); 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)); 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 || !imageBase64) return; setPhase("vision-thinking"); setPendingClick(click); try { const visionRes = await fetch("/api/vision", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session, prevImageBase64: imageBase64, 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 } = (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) { setError(String(e)); setPendingClick(null); setPhase("ready"); } } // ── Render ──────────────────────────────────────────────────────────── if (error) { return (

出 · 了 · 点 · 状 · 况

{error}

返 回
); } if (presentation) { return (
); } const sceneCount = session?.history.length ?? 0; const beatCount = visitedBeatsRef.current.length; return (
云梦
第 · {String(sceneCount).padStart(3, "0")} · 幕 · {String(beatCount).padStart(3, "0")} · 拍 · {session?.id.slice(2, 14) ?? "—"}
{phase === "loading-first" && (

正 · 在 · 唤 · 起 · 第 · 一 · 幕

)} {phase === "ready" && lastExitLabel && (

上 · 一 · 步 · {lastExitLabel}

)}
Ⅰ · Ⅰ
); } export default function PlayPage() { return ( 载入中 } > ); }