Files
Kokorone/apps/web/app/epilogue/EpilogueView.tsx
T
jiantw83andClaude Sonnet 5 1bea06e3cd feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能
新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、
時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙
測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 09:10:26 +08:00

229 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useState } from "react";
import {
fetchPersonalityDrift,
listCanonWorks,
listCharacters,
proposeEpilogueMilestone,
setTimeFlow,
type CanonWork,
type CharacterSummary,
type DriftedPersonality,
type MilestoneProposalResult,
type TimeFlowMode,
} from "../../lib/api";
const TIME_FLOW_LABELS: Record<TimeFlowMode, string> = {
FAST: "快轉",
REALTIME: "現實同步(1:1)",
SLOW: "放慢",
FROZEN: "凍結",
};
// N-6 後日談畫面:時間流速切換、人格漂移檢視、里程碑提案——三者都依「推演深淺」(storyOrder)運作,
// 預設值抓作品目前的 anchorStoryOrder,使用者可自行調整。
export function EpilogueView() {
const [works, setWorks] = useState<CanonWork[]>([]);
const [workId, setWorkId] = useState("");
const [work, setWork] = useState<CanonWork | null>(null);
const [characters, setCharacters] = useState<CharacterSummary[]>([]);
const [characterId, setCharacterId] = useState("");
const [storyOrder, setStoryOrder] = useState(0);
const [timeFlowError, setTimeFlowError] = useState<string | null>(null);
const [drift, setDrift] = useState<DriftedPersonality | null>(null);
const [milestoneResult, setMilestoneResult] = useState<MilestoneProposalResult | null>(null);
const [pending, setPending] = useState(false);
useEffect(() => {
void listCanonWorks().then(setWorks);
}, []);
useEffect(() => {
const selected = works.find((w) => w.id === workId) ?? null;
setWork(selected);
setStoryOrder(selected?.anchorStoryOrder ?? 0);
setDrift(null);
setMilestoneResult(null);
setTimeFlowError(null);
if (workId) {
void listCharacters().then((all) => setCharacters(all.filter((c) => c.workId === workId)));
} else {
setCharacters([]);
}
}, [workId, works]);
async function handleSetTimeFlow(mode: TimeFlowMode) {
if (!workId || pending) return;
setPending(true);
setTimeFlowError(null);
try {
const updated = await setTimeFlow(workId, mode);
setWork(updated);
setWorks((prev) => prev.map((w) => (w.id === updated.id ? updated : w)));
} catch (err) {
// N-6 驗收:受限情境要顯示被拒原因,這裡把 setTimeFlow() 丟出的後端 message 原樣呈現。
setTimeFlowError(err instanceof Error ? err.message : "時間流速設定失敗");
} finally {
setPending(false);
}
}
async function handleViewDrift() {
if (!characterId || pending) return;
setPending(true);
try {
setDrift(await fetchPersonalityDrift(characterId, storyOrder));
} catch {
setDrift(null);
} finally {
setPending(false);
}
}
async function handleProposeMilestone() {
if (!characterId || pending) return;
setPending(true);
try {
setMilestoneResult(await proposeEpilogueMilestone(characterId, storyOrder));
} catch {
setMilestoneResult(null);
} finally {
setPending(false);
}
}
return (
<div className="flex flex-col gap-4">
<select
value={workId}
onChange={(event) => setWorkId(event.target.value)}
className="rounded-full border bg-transparent px-3 py-2 text-sm"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
>
<option value="">選擇作品……</option>
{works.map((w) => (
<option key={w.id} value={w.id}>
{w.title}
</option>
))}
</select>
{work && (
<>
<section className="flex flex-col gap-2 rounded-2xl border p-4 text-xs" style={{ borderColor: "var(--line)" }}>
<h2 className="text-sm font-bold" style={{ color: "var(--ink)" }}>
時間流速
</h2>
<p style={{ color: "var(--ink-soft)" }}>
目前:{TIME_FLOW_LABELS[work.timeFlowMode]}
{work.inEpilogue ? ".已進入後日談" : ".尚未進入後日談"}
</p>
<div className="flex flex-wrap gap-2">
{(Object.keys(TIME_FLOW_LABELS) as TimeFlowMode[]).map((mode) => (
<button
key={mode}
type="button"
onClick={() => void handleSetTimeFlow(mode)}
disabled={pending}
className="rounded-full border px-3 py-1 disabled:opacity-50"
style={{
borderColor: "var(--line)",
background: work.timeFlowMode === mode ? "var(--chip-bg)" : "transparent",
color: "var(--ink)",
}}
>
{TIME_FLOW_LABELS[mode]}
</button>
))}
</div>
{timeFlowError && <p style={{ color: "var(--accent)" }}>{timeFlowError}</p>}
</section>
<select
value={characterId}
onChange={(event) => {
setCharacterId(event.target.value);
setDrift(null);
setMilestoneResult(null);
}}
className="rounded-full border bg-transparent px-3 py-2 text-sm"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
>
<option value="">選擇角色……</option>
{characters.map((character) => (
<option key={character.id} value={character.id}>
{character.aliases[0]?.formalName ?? character.id}
</option>
))}
</select>
{characterId && (
<>
<div className="flex items-center gap-2 text-xs">
<label style={{ color: "var(--ink-soft)" }}>推演深淺(storyOrder,預設抓作品目前的錨定點)</label>
<input
type="number"
value={storyOrder}
onChange={(event) => setStoryOrder(Number(event.target.value))}
className="w-24 rounded-full border bg-transparent px-3 py-1"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
/>
</div>
<section className="flex flex-col gap-2 rounded-2xl border p-4 text-xs" style={{ borderColor: "var(--line)" }}>
<div className="flex items-center justify-between">
<h2 className="text-sm font-bold" style={{ color: "var(--ink)" }}>
人格漂移
</h2>
<button
type="button"
onClick={() => void handleViewDrift()}
disabled={pending}
className="rounded-full px-3 py-1 font-bold text-white disabled:opacity-50"
style={{ background: "var(--accent-bright)" }}
>
查看
</button>
</div>
{drift && (
<p style={{ color: "var(--ink-soft)" }}>
性格核心:{drift.archetype}(不變).外顯度:{drift.expressiveness.toFixed(2)}(位移
{drift.driftAmount >= 0 ? "+" : ""}
{drift.driftAmount.toFixed(2)}).情緒衰減倍率:{drift.emotionHalfLifeMultiplier.toFixed(2)}
</p>
)}
</section>
<section className="flex flex-col gap-2 rounded-2xl border p-4 text-xs" style={{ borderColor: "var(--line)" }}>
<div className="flex items-center justify-between">
<h2 className="text-sm font-bold" style={{ color: "var(--ink)" }}>
人生里程碑
</h2>
<button
type="button"
onClick={() => void handleProposeMilestone()}
disabled={pending}
className="rounded-full px-3 py-1 font-bold text-white disabled:opacity-50"
style={{ background: "var(--accent-bright)" }}
>
提案
</button>
</div>
{milestoneResult && (
<p style={{ color: milestoneResult.label ? "var(--accent)" : "var(--ink-soft)" }}>
{milestoneResult.label
? `提案成功:${milestoneResult.label}`
: "此時沒有里程碑可提案(可能離上次太近,或角色目標裡沒有對應的正史伏筆)"}
</p>
)}
</section>
</>
)}
</>
)}
</div>
);
}