"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 { annotateClick } from "@/lib/annotateClient";
import { PRESETS } from "@/lib/presets";
import type {
Beat,
BeatAudio,
BeatAudioResponse,
BeatChoice,
InsertBeatResponse,
Scene,
SceneExit,
SceneResponse,
Session,
StartResponse,
VisionResponse,
} from "@infiplot/types";
import { track } from "@/lib/analytics";
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 usually <2s for a
// 1792×1024 PNG, but over slow links / VPN / strict corp networks the same
// download can stretch to 10-20s. The previous 8s ceiling fired in that
// window, and because the rendered has no aspect-ratio occupation, the
// layout collapsed to a one-pixel-tall sliver until the bytes actually
// finished arriving — "等了很久 → 一根线 → 突然出图" of the original report.
// 20s + the aspect-video fallback together remove that failure mode.
const IMAGE_PRELOAD_TIMEOUT_MS = 20000;
// ──────────────────────────────────────────────────────────────────────
// Image fetch → blob URL — bulletproof against browser progressive paint.
//
// Why not a plain : Runware CDN returns weak cache headers
// (every mount issues a fresh GET — confirmed in DevTools, status 200
// not "from disk cache"), so the Image() preload + decode() trick can warm
// HTTP cache but the actual still streams bytes from network and
// paints row-by-row as they arrive.
//
// Fix: fetch the bytes ourselves, materialize a blob: URL pointing at the
// fully-local copy, and only set the to that blob: URL. The
// never sees a network-backed src, so there is no "字节还在路上" middle state
// and no progressive paint is possible. Trade-off: callers MUST revoke the
// blob URL when swapping it out, or the bytes leak in JS heap.
//
// Failure mode: on network error / timeout we fall back to the original CDN
// URL so the still attempts to render (with possible progressive paint
// — same as pre-fix behavior, never worse).
//
// Data URIs (MOCK_IMAGE mode) are already local; passed through unchanged.
// ──────────────────────────────────────────────────────────────────────
// Optional Cloudflare Workers proxy in front of Runware. Reason: Chrome's
// direct fetch of im.runware.ai sometimes hits ERR_QUIC_PROTOCOL_ERROR
// mid-stream, leaving the browser with partial PNG bytes that render
// progressively. The Worker re-fetches Runware server-to-server (no QUIC
// fragility) and serves the bytes over HTTP/2 — atomic and reliable.
//
// Inlined by Next.js at build time. Empty / unset → fall back to direct
// fetch of the original URL (works fine when Runware's CDN cooperates,
// and on browsers/networks where QUIC isn't flaky).
const IMAGE_PROXY_BASE = (
process.env.NEXT_PUBLIC_IMAGE_PROXY_URL ?? ""
).replace(/\/$/, "");
function proxiedImageUrl(originalUrl: string): string {
if (!IMAGE_PROXY_BASE) return originalUrl;
// Data URIs (MOCK_IMAGE) are already local; proxy is irrelevant.
if (originalUrl.startsWith("data:")) return originalUrl;
// Only proxy real Runware CDN URLs — keeps the Worker's whitelist tight
// and dodges the proxy hop for any other origin we might add later.
if (!originalUrl.startsWith("https://im.runware.ai/")) return originalUrl;
return `${IMAGE_PROXY_BASE}/?url=${encodeURIComponent(originalUrl)}`;
}
async function fetchImageAsBlobUrl(url: string): Promise {
if (url.startsWith("data:")) return url;
// Cache keys (blobUrlCache) stay on the original Runware URL — the proxy
// is an internal fetch detail, callers shouldn't need to think about it.
const fetchUrl = proxiedImageUrl(url);
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), IMAGE_PRELOAD_TIMEOUT_MS);
try {
const r = await fetch(fetchUrl, { signal: ctrl.signal });
if (!r.ok) return url;
const blob = await r.blob();
return URL.createObjectURL(blob);
} catch {
return url;
} finally {
clearTimeout(timer);
}
}
// Module-level cache so speculative prefetches and the eventual commit share
// the same in-flight fetch — no double-download per scene. Keyed by the
// ORIGINAL CDN URL (the blob: URL it resolves to is the value). Persists for
// the page's lifetime; entries are explicitly revoked when the scene swaps.
const blobUrlCache = new Map>();
function getOrCreateBlobUrl(originalUrl: string): Promise {
let p = blobUrlCache.get(originalUrl);
if (!p) {
p = fetchImageAsBlobUrl(originalUrl);
blobUrlCache.set(originalUrl, p);
}
return p;
}
function revokeBlobUrlFor(originalUrl: string): void {
const p = blobUrlCache.get(originalUrl);
if (!p) return;
blobUrlCache.delete(originalUrl);
p.then((u) => {
if (u.startsWith("blob:")) URL.revokeObjectURL(u);
}).catch(() => {});
}
// ──────────────────────────────────────────────────────────────────────
// 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;
// Kick off the blob fetch for this URL so when the player eventually
// picks this choice, transitioning is a no-op cache lookup instead of a
// fresh CDN download. Don't await — let it run in the background; the
// transition path awaits the same cached promise via getOrCreateBlobUrl.
void getOrCreateBlobUrl(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,
storyState: data.storyState,
};
prefetchScenePath(pool, carriedBase, [...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 [imageUrl, setImageUrl] = useState(null);
const [beatAudioMap, setBeatAudioMap] = useState>({});
// Lazy-initialize 优先级:本局选择(homepage 的「语音配音」存到 sessionStorage:infiplot:custom)
// > 上次会话的粘性偏好(localStorage:infiplot:muted) > 默认非静音。
// 这样首页选了「关闭」开始游戏,进来就是静音;选「开启」就不是静音;进入 play 页后用户自己
// 切换 静音/有声 时再用 localStorage 持久化,下一局开新游戏 sessionStorage 选择会再覆盖。
const [muted, setMuted] = useState(() => {
if (typeof window === "undefined") return false;
try {
const stored = window.sessionStorage.getItem("infiplot:custom");
if (stored) {
const parsed = JSON.parse(stored) as { audioEnabled?: boolean };
if (typeof parsed.audioEnabled === "boolean") {
return !parsed.audioEnabled;
}
}
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(null);
const [presentation, setPresentation] = useState(false);
const [lastExitLabel, setLastExitLabel] = useState(null);
const startedRef = useRef(false);
const poolRef = useRef