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
+96
View File
@@ -0,0 +1,96 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { createRoom, listCharacters, type CharacterSummary, type RoomMode } from "../../lib/api";
const USER_ID = "seed-user-primary";
// K-7 建房介面:建立後導向 /room/[roomId](T19-P2 起的通用房間畫面,含 SSE/立繪舞台/語音/靜音,
// 見 T19-P3),不再自己維護一份較舊、功能較少的房間顯示邏輯。
export function RoomView() {
const router = useRouter();
const [characters, setCharacters] = useState<CharacterSummary[]>([]);
const [selectedCharacterIds, setSelectedCharacterIds] = useState<string[]>([]);
const [mode, setMode] = useState<RoomMode>("GROUP");
const [topicSeed, setTopicSeed] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void listCharacters().then(setCharacters);
}, []);
async function handleCreateRoom() {
if (selectedCharacterIds.length === 0 || pending) return;
setPending(true);
setError(null);
try {
const created = await createRoom(USER_ID, selectedCharacterIds, mode, topicSeed || undefined);
router.push(`/room/${created.id}`);
} catch {
setError("房間建立失敗,請確認 api 服務是否已啟動");
setPending(false);
}
}
return (
<div className="flex flex-col gap-3 rounded-2xl border p-4" style={{ borderColor: "var(--line)" }}>
<h2 className="text-sm font-bold" style={{ color: "var(--ink)" }}>
建立房間
</h2>
<div className="flex flex-wrap gap-2 text-xs">
{characters.map((character) => {
const checked = selectedCharacterIds.includes(character.id);
return (
<label
key={character.id}
className="flex items-center gap-1 rounded-full border px-2 py-1"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
>
<input
type="checkbox"
checked={checked}
onChange={() =>
setSelectedCharacterIds((prev) => (checked ? prev.filter((id) => id !== character.id) : [...prev, character.id]))
}
/>
{character.aliases[0]?.formalName ?? character.id}
</label>
);
})}
</div>
<div className="flex items-center gap-3 text-xs">
<label className="flex items-center gap-1" style={{ color: "var(--ink)" }}>
<input type="radio" checked={mode === "GROUP"} onChange={() => setMode("GROUP")} />
群聊
</label>
<label className="flex items-center gap-1" style={{ color: "var(--ink)" }}>
<input type="radio" checked={mode === "SELF_CHAT"} onChange={() => setMode("SELF_CHAT")} />
角色自聊(旁觀)
</label>
</div>
<input
value={topicSeed}
onChange={(event) => setTopicSeed(event.target.value)}
placeholder="話題種子(選填)"
className="rounded-full border bg-transparent px-3 py-1.5 text-xs"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
/>
<button
type="button"
onClick={() => void handleCreateRoom()}
disabled={pending || selectedCharacterIds.length === 0}
className="self-start rounded-full px-4 py-1.5 text-xs font-bold text-white disabled:opacity-50"
style={{ background: "var(--accent-bright)" }}
>
建立房間
</button>
{error && (
<p className="text-xs" style={{ color: "var(--accent)" }}>
{error}
</p>
)}
</div>
);
}