feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能

新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、
時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙
測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 09:10:26 +08:00
co-authored by Claude Sonnet 5
parent 127a59dafd
commit 1bea06e3cd
195 changed files with 17219 additions and 738 deletions
+228
View File
@@ -0,0 +1,228 @@
"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>
);
}