Merge pull request #52 from zonghaoyuan/feat/story-share
feat(play): add encrypted story sharing with replay
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import { packDoc } from "@/lib/galleryCrypto";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const MAX_DOC_BYTES = 12_000_000;
|
||||
|
||||
export async function POST(req: Request): Promise<Response> {
|
||||
const secret = process.env.GALLERY_SECRET;
|
||||
if (!secret) {
|
||||
return Response.json(
|
||||
{ error: "剧情分享未启用 (GALLERY_SECRET 未配置)" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
const contentLength = req.headers.get("content-length");
|
||||
if (contentLength && Number(contentLength) > MAX_DOC_BYTES + 1024) {
|
||||
return Response.json(
|
||||
{ error: "剧情数据太大,无法打包分享" },
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
|
||||
let docStr: string;
|
||||
try {
|
||||
const body = (await req.json()) as { docStr?: unknown };
|
||||
if (typeof body.docStr !== "string") {
|
||||
return Response.json({ error: "Missing docStr" }, { status: 400 });
|
||||
}
|
||||
docStr = body.docStr;
|
||||
} catch {
|
||||
return Response.json({ error: "Bad JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (new TextEncoder().encode(docStr).byteLength > MAX_DOC_BYTES) {
|
||||
return Response.json(
|
||||
{ error: "剧情数据太大,无法打包分享" },
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
|
||||
const bytes = await packDoc(docStr, secret);
|
||||
const ab = new ArrayBuffer(bytes.byteLength);
|
||||
new Uint8Array(ab).set(bytes);
|
||||
return new Response(ab, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { unpackDoc } from "@/lib/galleryCrypto";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const MAX_FILE_BYTES = 13_000_000;
|
||||
|
||||
export async function POST(req: Request): Promise<Response> {
|
||||
const secret = process.env.GALLERY_SECRET;
|
||||
if (!secret) {
|
||||
return Response.json(
|
||||
{ error: "剧情分享未启用 (GALLERY_SECRET 未配置)" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
const contentLength = req.headers.get("content-length");
|
||||
if (contentLength && Number(contentLength) > MAX_FILE_BYTES) {
|
||||
return Response.json({ error: "文件太大" }, { status: 413 });
|
||||
}
|
||||
|
||||
let ab: ArrayBuffer;
|
||||
try {
|
||||
ab = await req.arrayBuffer();
|
||||
} catch {
|
||||
return Response.json({ error: "Bad request body" }, { status: 400 });
|
||||
}
|
||||
if (ab.byteLength > MAX_FILE_BYTES) {
|
||||
return Response.json({ error: "文件太大" }, { status: 413 });
|
||||
}
|
||||
if (ab.byteLength === 0) {
|
||||
return Response.json({ error: "文件为空" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const docStr = await unpackDoc(new Uint8Array(ab), secret);
|
||||
return Response.json({ docStr });
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ error: "剧情文件解包失败" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@/lib/options";
|
||||
import { readStoredTtsConfig } from "@/lib/clientTtsConfig";
|
||||
import { SettingsModal, readStoredPlayerName, readStoredVisionClick } from "@/components/SettingsModal";
|
||||
import { STORY_SHARE_STORAGE_KEY, parseStoryShareDoc } from "@/lib/storyShare";
|
||||
|
||||
/* ============================================================================
|
||||
InfiPlot · 首页(编辑式视觉风格 · 居中构图,呼应低保真原型)
|
||||
@@ -1249,6 +1250,8 @@ export default function HomePage() {
|
||||
const [customStyleGuide, setCustomStyleGuide] = useState("");
|
||||
const [customStyleRefImage, setCustomStyleRefImage] = useState<string>("");
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const storyImportRef = useRef<HTMLInputElement>(null);
|
||||
const [storyImportError, setStoryImportError] = useState<string | null>(null);
|
||||
|
||||
// 顶部使用提示:默认展示,用户可点 × 永久关闭(localStorage:infiplot:hintClosed)。
|
||||
const [hintClosed, setHintClosed] = useState(false);
|
||||
@@ -1396,6 +1399,46 @@ export default function HomePage() {
|
||||
router.push("/play?custom=1");
|
||||
};
|
||||
|
||||
const handleStoryImport = async (file: File | undefined) => {
|
||||
setStoryImportError(null);
|
||||
if (!file) return;
|
||||
if (file.size <= 0) {
|
||||
setStoryImportError("这个剧情文件是空的。");
|
||||
return;
|
||||
}
|
||||
const isJson = file.name.toLowerCase().endsWith(".json") || file.type === "application/json";
|
||||
const maxImportBytes = isJson ? 12_000_000 : 13_000_000;
|
||||
if (file.size > maxImportBytes) {
|
||||
setStoryImportError("剧情文件太大,无法载入。");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let text: string;
|
||||
if (isJson) {
|
||||
text = await file.text();
|
||||
} else {
|
||||
const r = await fetch("/api/story-unpack", {
|
||||
method: "POST",
|
||||
body: await file.arrayBuffer(),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const j = (await r.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(j.error ?? "剧情文件解包失败。");
|
||||
}
|
||||
const j = (await r.json()) as { docStr?: unknown };
|
||||
if (typeof j.docStr !== "string") throw new Error("剧情文件解包失败。");
|
||||
text = j.docStr;
|
||||
}
|
||||
const doc = parseStoryShareDoc(JSON.parse(text));
|
||||
window.sessionStorage.setItem(STORY_SHARE_STORAGE_KEY, JSON.stringify(doc));
|
||||
router.push("/play?share=1");
|
||||
} catch (e) {
|
||||
setStoryImportError(e instanceof Error ? e.message : "剧情文件解析失败。");
|
||||
} finally {
|
||||
if (storyImportRef.current) storyImportRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const stories = STORIES[galleryGender];
|
||||
const imgPrefix = galleryGender === "女性向" ? "f" : "m";
|
||||
const analyticsOn = Boolean(
|
||||
@@ -1510,7 +1553,29 @@ export default function HomePage() {
|
||||
开始
|
||||
<i className="fa-solid fa-arrow-right text-xs" />
|
||||
</button>
|
||||
<input
|
||||
ref={storyImportRef}
|
||||
type="file"
|
||||
accept=".infiplot,application/octet-stream,.json,application/json"
|
||||
className="hidden"
|
||||
onChange={(e) => void handleStoryImport(e.target.files?.[0])}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => storyImportRef.current?.click()}
|
||||
className="group absolute right-[-2.25rem] bottom-2 md:bottom-3 inline-flex items-center justify-center rounded-sm border border-clay-900/20 px-2 py-2 md:py-2.5 text-clay-400 transition-colors hover:border-ember-500 hover:text-ember-500"
|
||||
>
|
||||
<i className="fa-solid fa-file-import text-sm" />
|
||||
<span className="pointer-events-none absolute -bottom-8 left-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-clay-900 px-2 py-1 font-sans text-[11px] text-cream-50 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
载入剧情
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{storyImportError && (
|
||||
<p className="mt-2 text-right text-xs leading-relaxed text-ember-500">
|
||||
{storyImportError}
|
||||
</p>
|
||||
)}
|
||||
{prompt && (
|
||||
<p className="mt-2 text-right text-xs text-clay-400">
|
||||
Enter 发送 · Shift+Enter 换行
|
||||
|
||||
+294
-10
@@ -21,6 +21,12 @@ import { SettingsModal, readStoredPlayerName, readStoredVisionClick } from "@/co
|
||||
import { annotateClick } from "@/lib/annotateClient";
|
||||
import { loadClientTtsConfig } from "@/lib/clientTtsConfig";
|
||||
import { PRESETS } from "@/lib/presets";
|
||||
import {
|
||||
STORY_SHARE_STORAGE_KEY,
|
||||
createStoryShareDoc,
|
||||
parseStoryShareDoc,
|
||||
storyShareFilename,
|
||||
} from "@/lib/storyShare";
|
||||
import { provisionVoice, synthesize } from "@infiplot/tts-client";
|
||||
import type {
|
||||
Beat,
|
||||
@@ -621,6 +627,9 @@ function PlayInner() {
|
||||
const currentSceneRef = useRef<Scene | null>(null);
|
||||
const currentBeatRef = useRef<Beat | null>(null);
|
||||
const visitedBeatsRef = useRef<string[]>([]);
|
||||
const replaySourceRef = useRef<Session | null>(null);
|
||||
const replayIndexRef = useRef(-1);
|
||||
const replayActiveRef = useRef(false);
|
||||
// Original (CDN) URL of the currently-rendered scene image. Used as the key
|
||||
// to revoke its blob: URL when the scene swaps. We track the ORIGINAL URL,
|
||||
// not the blob URL, because blobUrlCache is keyed by original URL.
|
||||
@@ -876,6 +885,13 @@ function PlayInner() {
|
||||
[prefetchSceneAudio],
|
||||
);
|
||||
|
||||
function detachRecordedReplay(): void {
|
||||
replayActiveRef.current = false;
|
||||
replaySourceRef.current = null;
|
||||
replayIndexRef.current = -1;
|
||||
clearPool(poolRef.current);
|
||||
}
|
||||
|
||||
// ── Export to interactive gallery (PPT-style replay) ─────────────────
|
||||
// Drop all but the (keepCount) most-recent gallery exports from localStorage,
|
||||
// ordered by their stored createdAt. Called right before writing a new
|
||||
@@ -1034,6 +1050,42 @@ function PlayInner() {
|
||||
})();
|
||||
}, [trimGalleryExports]);
|
||||
|
||||
const handleExportStory = useCallback(() => {
|
||||
const s = sessionRef.current;
|
||||
if (!s || s.history.length === 0) return;
|
||||
const sceneIndex = Math.max(0, s.history.length - 1);
|
||||
const doc = createStoryShareDoc(s, {
|
||||
sceneIndex,
|
||||
beatId: currentBeatRef.current?.id ?? s.history[sceneIndex]?.scene.entryBeatId,
|
||||
});
|
||||
void (async () => {
|
||||
try {
|
||||
const r = await fetch("/api/story-pack", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ docStr: JSON.stringify(doc) }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const j = (await r.json().catch(() => ({}))) as { error?: string };
|
||||
window.alert(j.error ?? "剧情分享打包失败");
|
||||
return;
|
||||
}
|
||||
const blob = await r.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = storyShareFilename(doc);
|
||||
a.rel = "noopener";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 2000);
|
||||
} catch {
|
||||
window.alert("剧情分享打包失败");
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// ── Presentation mode toggle ─────────────────────────────────────────
|
||||
const togglePresentation = useCallback(async () => {
|
||||
const entering = !presentation;
|
||||
@@ -1098,9 +1150,60 @@ function PlayInner() {
|
||||
// ?preset=<id> → 内置 PRESETS(仍走 /api/start 现场生成)
|
||||
// ?custom=1 → 用户自定义 prompt,sessionStorage 取 ws/sg
|
||||
// 后走 /api/start 现场生成
|
||||
// ?share=1 → 首页上传的剧情分享 JSON,从第一幕开始本地回放
|
||||
const cardName = params.get("card");
|
||||
const presetId = params.get("preset");
|
||||
const isCustom = params.get("custom") === "1";
|
||||
const isShare = params.get("share") === "1";
|
||||
|
||||
if (isShare) {
|
||||
(async () => {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORY_SHARE_STORAGE_KEY);
|
||||
if (!raw) throw new Error("没有找到要载入的剧情文件。");
|
||||
const doc = parseStoryShareDoc(JSON.parse(raw));
|
||||
const imported = doc.session;
|
||||
const first = imported.history[0];
|
||||
if (!first) throw new Error("剧情分享文件没有可载入的剧情。");
|
||||
if (!first.scene.imageUrl) throw new Error("剧情分享文件缺少第一幕图片。");
|
||||
|
||||
const sessionOrientation =
|
||||
first.scene.orientation ?? imported.orientation ?? detectOrientation();
|
||||
setOrientation(sessionOrientation);
|
||||
const blobUrl = await getOrCreateBlobUrl(first.scene.imageUrl);
|
||||
lastImageOriginalUrlRef.current = first.scene.imageUrl;
|
||||
|
||||
const initialStoryState = first.storyStateAfter ?? imported.storyState;
|
||||
if (!initialStoryState) throw new Error("剧情分享文件缺少初始剧情记忆,无法载入。");
|
||||
|
||||
const initial: Session = {
|
||||
...imported,
|
||||
history: [
|
||||
{
|
||||
...first,
|
||||
visitedBeatIds: [first.scene.entryBeatId],
|
||||
exit: undefined,
|
||||
},
|
||||
],
|
||||
storyState: initialStoryState,
|
||||
orientation: sessionOrientation,
|
||||
};
|
||||
replaySourceRef.current = imported;
|
||||
replayIndexRef.current = 0;
|
||||
replayActiveRef.current = imported.history.length > 1;
|
||||
visitedBeatsRef.current = [first.scene.entryBeatId];
|
||||
setSession(initial);
|
||||
setCurrentScene(first.scene);
|
||||
setCurrentBeatId(first.scene.entryBeatId);
|
||||
setImageUrl(blobUrl);
|
||||
setPhase("ready");
|
||||
track("scene_reached", { scene_index: 1 });
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
let livePayload: {
|
||||
worldSetting: string;
|
||||
@@ -1224,6 +1327,7 @@ function PlayInner() {
|
||||
{
|
||||
scene: data.scene,
|
||||
visitedBeatIds: [data.scene.entryBeatId],
|
||||
storyStateAfter: data.storyState,
|
||||
},
|
||||
],
|
||||
characters: data.characters,
|
||||
@@ -1250,6 +1354,7 @@ function PlayInner() {
|
||||
const s = session;
|
||||
const scene = currentScene;
|
||||
if (!s || !scene) return;
|
||||
if (isRecordedReplayLockedAt(currentBeat)) return;
|
||||
|
||||
const exits = findAllChangeSceneChoices(scene);
|
||||
for (const choice of exits) {
|
||||
@@ -1346,6 +1451,7 @@ function PlayInner() {
|
||||
{
|
||||
scene: result.scene,
|
||||
visitedBeatIds: [result.scene.entryBeatId],
|
||||
storyStateAfter: result.storyState,
|
||||
},
|
||||
],
|
||||
characters: mergeCharactersPreserveVoice(
|
||||
@@ -1373,8 +1479,140 @@ function PlayInner() {
|
||||
}
|
||||
}
|
||||
|
||||
function tryRecordedSceneTransition(
|
||||
choice: BeatChoice,
|
||||
exit: SceneExit,
|
||||
visitedForCurrent: string[],
|
||||
): boolean {
|
||||
const source = replaySourceRef.current;
|
||||
const idx = replayIndexRef.current;
|
||||
if (!source || idx < 0 || !isRecordedReplayLockedAt(currentBeatRef.current)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const recorded = source.history[idx];
|
||||
const next = source.history[idx + 1];
|
||||
if (
|
||||
!recorded ||
|
||||
!next ||
|
||||
recorded.exit?.kind !== "choice" ||
|
||||
recorded.exit.choiceId !== choice.id
|
||||
) {
|
||||
detachRecordedReplay();
|
||||
return false;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
setPhase("transitioning");
|
||||
setPendingClick(null);
|
||||
try {
|
||||
if (!next.scene.imageUrl) throw new Error("剧情分享文件缺少下一幕图片。");
|
||||
const blobUrl = await getOrCreateBlobUrl(next.scene.imageUrl);
|
||||
const priorOriginal = lastImageOriginalUrlRef.current;
|
||||
if (priorOriginal && priorOriginal !== next.scene.imageUrl) {
|
||||
revokeBlobUrlFor(priorOriginal);
|
||||
}
|
||||
lastImageOriginalUrlRef.current = next.scene.imageUrl;
|
||||
|
||||
const base = sessionRef.current;
|
||||
if (!base) throw new Error("Session lost mid-replay");
|
||||
const closedHistory = base.history.map((h, i, arr) =>
|
||||
i === arr.length - 1
|
||||
? { ...h, visitedBeatIds: visitedForCurrent, exit }
|
||||
: h,
|
||||
);
|
||||
const nextIndex = idx + 1;
|
||||
const nextSession: Session = {
|
||||
...base,
|
||||
history: [
|
||||
...closedHistory,
|
||||
{
|
||||
...next,
|
||||
visitedBeatIds: [next.scene.entryBeatId],
|
||||
exit: undefined,
|
||||
},
|
||||
],
|
||||
characters: source.characters,
|
||||
storyState: next.storyStateAfter ?? base.storyState,
|
||||
orientation: next.scene.orientation ?? base.orientation,
|
||||
};
|
||||
replayIndexRef.current = nextIndex;
|
||||
replayActiveRef.current = true;
|
||||
visitedBeatsRef.current = [next.scene.entryBeatId];
|
||||
setSession(nextSession);
|
||||
setCurrentScene(next.scene);
|
||||
setCurrentBeatId(next.scene.entryBeatId);
|
||||
setImageUrl(blobUrl);
|
||||
setLastExitLabel(choice.label);
|
||||
setPhase("ready");
|
||||
track("scene_reached", { scene_index: nextSession.history.length });
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setPhase("ready");
|
||||
}
|
||||
})();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function recordedAllowedChoiceIds(beat: Beat | null): Set<string> | null {
|
||||
if (!replaySourceRef.current || !beat || beat.next.type !== "choice") return null;
|
||||
const source = replaySourceRef.current;
|
||||
const recorded = source?.history[replayIndexRef.current];
|
||||
if (!recorded) return new Set();
|
||||
|
||||
const visited = recorded.visitedBeatIds;
|
||||
const beatIdx = visited.indexOf(beat.id);
|
||||
if (beatIdx < 0) return null;
|
||||
const nextVisited = beatIdx >= 0 ? visited[beatIdx + 1] : undefined;
|
||||
const allowed = new Set<string>();
|
||||
if (nextVisited) {
|
||||
for (const choice of beat.next.choices) {
|
||||
if (
|
||||
choice.effect.kind === "advance-beat" &&
|
||||
choice.effect.targetBeatId === nextVisited
|
||||
) {
|
||||
allowed.add(choice.id);
|
||||
}
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
|
||||
if (
|
||||
beatIdx === visited.length - 1 &&
|
||||
recorded.exit?.kind === "choice" &&
|
||||
source.history[replayIndexRef.current + 1]
|
||||
) {
|
||||
allowed.add(recorded.exit.choiceId);
|
||||
return allowed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isRecordedReplayLockedAt(beat: Beat | null): boolean {
|
||||
if (!replaySourceRef.current || !beat) return false;
|
||||
const recorded = replaySourceRef.current.history[replayIndexRef.current];
|
||||
if (!recorded) return false;
|
||||
const beatIdx = recorded.visitedBeatIds.indexOf(beat.id);
|
||||
if (beatIdx < 0) return false;
|
||||
return Boolean(
|
||||
recorded.visitedBeatIds[beatIdx + 1] ||
|
||||
(
|
||||
beatIdx === recorded.visitedBeatIds.length - 1 &&
|
||||
recorded.exit?.kind === "choice" &&
|
||||
replaySourceRef.current.history[replayIndexRef.current + 1]
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function isDisabledByRecordedReplay(choice: BeatChoice): boolean {
|
||||
const allowed = recordedAllowedChoiceIds(currentBeatRef.current);
|
||||
return allowed !== null && !allowed.has(choice.id);
|
||||
}
|
||||
|
||||
function onSelectChoice(choice: BeatChoice) {
|
||||
if (phase !== "ready" || !session || !currentScene) return;
|
||||
if (isDisabledByRecordedReplay(choice)) return;
|
||||
|
||||
const beatNext = currentBeatRef.current?.next;
|
||||
const choiceIndex =
|
||||
@@ -1390,6 +1628,22 @@ function PlayInner() {
|
||||
}
|
||||
|
||||
if (choice.effect.kind === "advance-beat") {
|
||||
if (replayActiveRef.current && currentBeatRef.current) {
|
||||
const source = replaySourceRef.current;
|
||||
const idx = replayIndexRef.current;
|
||||
const recorded = source?.history[idx];
|
||||
const recordedVisited = recorded?.visitedBeatIds ?? [];
|
||||
const beatIdx = recordedVisited.indexOf(currentBeatRef.current.id);
|
||||
const recordedNext = beatIdx >= 0 ? recordedVisited[beatIdx + 1] : undefined;
|
||||
if (recordedNext && recordedNext !== choice.effect.targetBeatId) {
|
||||
detachRecordedReplay();
|
||||
}
|
||||
} else if (
|
||||
replaySourceRef.current &&
|
||||
!isRecordedReplayLockedAt(currentBeatRef.current)
|
||||
) {
|
||||
detachRecordedReplay();
|
||||
}
|
||||
// Pure local jump. No network. No pool changes.
|
||||
setCurrentBeatId(choice.effect.targetBeatId);
|
||||
return;
|
||||
@@ -1403,6 +1657,9 @@ function PlayInner() {
|
||||
nextSceneSeed: choice.effect.nextSceneSeed,
|
||||
};
|
||||
|
||||
if (tryRecordedSceneTransition(choice, exit, visited)) return;
|
||||
if (replaySourceRef.current) detachRecordedReplay();
|
||||
|
||||
const cached = consumeChoice(poolRef.current, choice.id);
|
||||
if (cached) {
|
||||
void performSceneTransition(cached, exit, visited, choice.label);
|
||||
@@ -1445,6 +1702,7 @@ function PlayInner() {
|
||||
|
||||
async function onFreeformInput(text: string) {
|
||||
if (phase !== "ready" || !session || !currentScene) return;
|
||||
if (replayActiveRef.current) detachRecordedReplay();
|
||||
|
||||
track("freeform_input", {
|
||||
scene_index: session.history.length,
|
||||
@@ -1576,6 +1834,7 @@ function PlayInner() {
|
||||
|
||||
async function onBackgroundClick(click: { x: number; y: number }) {
|
||||
if (phase !== "ready" || !session || !currentScene || !imageUrl) return;
|
||||
if (replayActiveRef.current) detachRecordedReplay();
|
||||
setPhase("vision-thinking");
|
||||
setPendingClick(click);
|
||||
|
||||
@@ -1720,6 +1979,15 @@ function PlayInner() {
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────
|
||||
|
||||
const replayAllowedChoiceIds = recordedAllowedChoiceIds(currentBeat);
|
||||
const disabledReplayChoiceIds =
|
||||
replayAllowedChoiceIds && currentBeat?.next.type === "choice"
|
||||
? currentBeat.next.choices
|
||||
.filter((choice) => !replayAllowedChoiceIds.has(choice.id))
|
||||
.map((choice) => choice.id)
|
||||
: [];
|
||||
const replayLocked = isRecordedReplayLockedAt(currentBeat);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center px-8">
|
||||
@@ -1768,6 +2036,8 @@ function PlayInner() {
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
fullViewport
|
||||
dialogueHistory={dialogueHistory}
|
||||
disabledChoiceIds={disabledReplayChoiceIds}
|
||||
freeformDisabled={replayLocked}
|
||||
/>
|
||||
{orientation === "portrait" && (
|
||||
<div
|
||||
@@ -1854,6 +2124,8 @@ function PlayInner() {
|
||||
visionClickEnabled={visionClickEnabled}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
dialogueHistory={dialogueHistory}
|
||||
disabledChoiceIds={disabledReplayChoiceIds}
|
||||
freeformDisabled={replayLocked}
|
||||
aboveCanvas={
|
||||
<button
|
||||
type="button"
|
||||
@@ -1868,16 +2140,28 @@ function PlayInner() {
|
||||
}
|
||||
belowCanvas={
|
||||
session && session.history.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExportGallery}
|
||||
className="text-[10px] smallcaps text-clay-500 hover:text-ember-500 transition-colors flex items-center gap-2"
|
||||
aria-label="导出可交互图集"
|
||||
title="导出本局为可交互图集链接(只会保留最近两次的可交互图集链接)"
|
||||
>
|
||||
<i className="fa-solid fa-link text-[10px]" />
|
||||
导 · 出 · 图 · 集
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExportGallery}
|
||||
className="text-[10px] smallcaps text-clay-500 hover:text-ember-500 transition-colors flex items-center gap-2"
|
||||
aria-label="导出可交互图集"
|
||||
title="导出本局为可交互图集链接(只会保留最近两次的可交互图集链接)"
|
||||
>
|
||||
<i className="fa-solid fa-link text-[10px]" />
|
||||
导 · 出 · 图 · 集
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExportStory}
|
||||
className="text-[10px] smallcaps text-clay-500 hover:text-ember-500 transition-colors flex items-center gap-2"
|
||||
aria-label="分享当前剧情"
|
||||
title="导出本局为可继续游玩的剧情 JSON"
|
||||
>
|
||||
<i className="fa-solid fa-share-nodes text-[10px]" />
|
||||
分 · 享 · 剧 · 情
|
||||
</button>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
aboveCanvasLeft={
|
||||
|
||||
Reference in New Issue
Block a user