feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能
新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、 時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙 測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user