feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能
新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、 時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙 測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
import { VoicePlaybackButton } from "./VoicePlaybackButton";
|
||||
|
||||
interface ChatBubbleProps {
|
||||
role: "user" | "character";
|
||||
text: string;
|
||||
characterId?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
// H-3 動作描寫標記渲染:把 *…* 標記以暮空紫小字呈現,與台詞區分。
|
||||
@@ -19,28 +23,30 @@ function renderContent(text: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function ChatBubble({ role, text }: ChatBubbleProps) {
|
||||
export function ChatBubble({ role, text, characterId, userId }: ChatBubbleProps) {
|
||||
const isUser = role === "user";
|
||||
return (
|
||||
<div
|
||||
className="max-w-[78%] rounded-2xl px-3.5 py-2 text-sm leading-relaxed"
|
||||
style={
|
||||
isUser
|
||||
? {
|
||||
alignSelf: "flex-end",
|
||||
background: "var(--accent-bright)",
|
||||
color: "#fff",
|
||||
borderBottomRightRadius: 4,
|
||||
}
|
||||
: {
|
||||
alignSelf: "flex-start",
|
||||
background: "var(--bg-soft)",
|
||||
border: "1px solid var(--line)",
|
||||
borderBottomLeftRadius: 4,
|
||||
}
|
||||
}
|
||||
>
|
||||
{renderContent(text)}
|
||||
<div className="flex max-w-[78%] flex-col" style={{ alignSelf: isUser ? "flex-end" : "flex-start", alignItems: isUser ? "flex-end" : "flex-start" }}>
|
||||
<div
|
||||
className="rounded-2xl px-3.5 py-2 text-sm leading-relaxed"
|
||||
style={
|
||||
isUser
|
||||
? {
|
||||
background: "var(--accent-bright)",
|
||||
color: "#fff",
|
||||
borderBottomRightRadius: 4,
|
||||
}
|
||||
: {
|
||||
background: "var(--bg-soft)",
|
||||
border: "1px solid var(--line)",
|
||||
borderBottomLeftRadius: 4,
|
||||
}
|
||||
}
|
||||
>
|
||||
{renderContent(text)}
|
||||
</div>
|
||||
{/* P-6:語音播放介面只在角色訊息旁提供,使用者自己的訊息不需要播放。 */}
|
||||
{!isUser && characterId && userId && <VoicePlaybackButton characterId={characterId} userId={userId} text={text} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
attemptRomanceMilestone,
|
||||
confessRomance,
|
||||
fetchRomanceState,
|
||||
type ConfessionOutcome,
|
||||
type MilestoneResult,
|
||||
type MilestoneType,
|
||||
type RomanceStage,
|
||||
type RomanceState,
|
||||
} from "../lib/api";
|
||||
|
||||
const ROMANCE_STAGE_LABELS: Record<RomanceStage, string> = {
|
||||
NONE: "朋友(戀愛軸未啟動)",
|
||||
INTERESTED: "在意",
|
||||
AMBIGUOUS: "曖昧",
|
||||
PARTNER: "戀人",
|
||||
DEEP_PARTNER: "深度伴侶",
|
||||
CRISIS: "關係危機",
|
||||
};
|
||||
|
||||
const CONFESSION_OUTCOME_LABELS: Record<ConfessionOutcome, string> = {
|
||||
INELIGIBLE: "目前角色不具戀愛軸資格,無法告白。",
|
||||
REJECTED_TOO_SOON: "現在提還太尷尬了,她需要一點時間緩一緩。",
|
||||
REJECTED: "她婉拒了……心動值受到影響。",
|
||||
ACCEPTED: "她答應了!你們正式成為戀人。",
|
||||
};
|
||||
|
||||
const MILESTONE_LABELS: Record<MilestoneType, string> = {
|
||||
DISTANCE_SHORTENED: "拉近距離",
|
||||
OCCASIONAL_CONTACT: "偶爾的肢體接觸",
|
||||
LATE_NIGHT_TALK: "深夜談心",
|
||||
HAND_HOLDING: "牽手",
|
||||
HUG: "擁抱",
|
||||
FOREHEAD_KISS: "額頭吻",
|
||||
CASUAL_INTIMACY: "日常親密互動",
|
||||
};
|
||||
|
||||
interface RomanceSectionProps {
|
||||
characterId: string;
|
||||
userId: string;
|
||||
refreshKey: number; // 每完成一輪對話就遞增,讓戀愛狀態隨事件即時更新
|
||||
}
|
||||
|
||||
// L-9:對話介面顯示心動值與戀愛階段,並提供告白/里程碑操作。
|
||||
// 「不得把失敗畫成成功」:confess/milestone 的結果一律照 outcome/allowed 如實呈現,不做樂觀 UI。
|
||||
export function RomanceSection({ characterId, userId, refreshKey }: RomanceSectionProps) {
|
||||
const [state, setState] = useState<RomanceState | null>(null);
|
||||
const [confessionMessage, setConfessionMessage] = useState<{ outcome: ConfessionOutcome } | null>(null);
|
||||
const [milestoneType, setMilestoneType] = useState<MilestoneType>("DISTANCE_SHORTENED");
|
||||
const [milestoneResult, setMilestoneResult] = useState<MilestoneResult | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
setState(await fetchRomanceState(characterId, userId));
|
||||
} catch {
|
||||
// 戀愛狀態查詢失敗不影響對話本身,維持上一次已知狀態。
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [characterId, userId, refreshKey]);
|
||||
|
||||
async function handleConfess() {
|
||||
setPending(true);
|
||||
setConfessionMessage(null);
|
||||
try {
|
||||
const result = await confessRomance(characterId, userId);
|
||||
setConfessionMessage({ outcome: result.outcome });
|
||||
await refresh();
|
||||
} catch {
|
||||
setConfessionMessage(null);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMilestone() {
|
||||
setPending(true);
|
||||
setMilestoneResult(null);
|
||||
try {
|
||||
const result = await attemptRomanceMilestone(characterId, userId, milestoneType);
|
||||
setMilestoneResult(result);
|
||||
await refresh();
|
||||
} catch {
|
||||
setMilestoneResult(null);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-2xl border p-3 text-xs" style={{ borderColor: "var(--line)" }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span style={{ color: "var(--ink-soft)" }}>
|
||||
心動值 <b style={{ color: "var(--accent)" }}>{state.romanceMeter}</b>
|
||||
</span>
|
||||
<span className="rounded-full px-2 py-0.5 font-bold" style={{ background: "var(--chip-bg)", color: "var(--accent)" }}>
|
||||
{ROMANCE_STAGE_LABELS[state.romanceStage]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleConfess()}
|
||||
disabled={pending}
|
||||
className="rounded-full px-3 py-1 font-bold text-white disabled:opacity-50"
|
||||
style={{ background: "var(--accent-bright)" }}
|
||||
>
|
||||
告白
|
||||
</button>
|
||||
<select
|
||||
value={milestoneType}
|
||||
onChange={(event) => setMilestoneType(event.target.value as MilestoneType)}
|
||||
disabled={pending}
|
||||
className="rounded-full border bg-transparent px-2 py-1"
|
||||
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
|
||||
>
|
||||
{Object.entries(MILESTONE_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleMilestone()}
|
||||
disabled={pending}
|
||||
className="rounded-full border px-3 py-1 disabled:opacity-50"
|
||||
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
|
||||
>
|
||||
嘗試
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{confessionMessage && (
|
||||
<p style={{ color: confessionMessage.outcome === "ACCEPTED" ? "var(--accent)" : "var(--ink-soft)" }}>
|
||||
{CONFESSION_OUTCOME_LABELS[confessionMessage.outcome]}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{milestoneResult && (
|
||||
<p style={{ color: milestoneResult.allowed ? "var(--accent)" : "var(--ink-soft)" }}>
|
||||
{milestoneResult.allowed
|
||||
? `${milestoneResult.isFirstTime ? "(第一次)" : ""}她接受了這個舉動。`
|
||||
: milestoneResult.reactionLine ?? "現在還不是時候。"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { TachieStage, type TachieExpression } from "./TachieStage";
|
||||
|
||||
export interface StageParticipant {
|
||||
characterId: string;
|
||||
label: string;
|
||||
expression: TachieExpression | null;
|
||||
}
|
||||
|
||||
interface RoomStageProps {
|
||||
participants: StageParticipant[];
|
||||
speakingCharacterId: string | null;
|
||||
}
|
||||
|
||||
// T19-P3:多人房立繪舞台——目前發話者放大高亮,人多時整體縮小排列,避免超出可視範圍。
|
||||
export function RoomStage({ participants, speakingCharacterId }: RoomStageProps) {
|
||||
if (participants.length === 0) return null;
|
||||
const scale = participants.length <= 3 ? 1 : participants.length <= 6 ? 0.7 : 0.5;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-end justify-center gap-2 overflow-x-auto rounded-2xl border p-2"
|
||||
style={{ borderColor: "var(--line)", background: "linear-gradient(180deg, var(--bg-soft), var(--card))" }}
|
||||
>
|
||||
{participants.map((participant) => {
|
||||
const speaking = participant.characterId === speakingCharacterId;
|
||||
return (
|
||||
<div
|
||||
key={participant.characterId}
|
||||
className="flex shrink-0 flex-col items-center gap-1"
|
||||
style={{
|
||||
transform: `scale(${speaking ? scale * 1.08 : scale})`,
|
||||
opacity: speakingCharacterId && !speaking ? 0.55 : 1,
|
||||
transition: "transform 200ms ease, opacity 200ms ease",
|
||||
}}
|
||||
>
|
||||
<div className="rounded-xl" style={speaking ? { boxShadow: "0 0 0 2px var(--accent-bright)" } : undefined}>
|
||||
<TachieStage expression={participant.expression} />
|
||||
</div>
|
||||
<span className="text-[10px]" style={{ color: speaking ? "var(--accent)" : "var(--ink-soft)" }}>
|
||||
{participant.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { synthesizeVoice } from "../lib/api";
|
||||
|
||||
type PlaybackState = "idle" | "loading" | "playing" | "unavailable";
|
||||
|
||||
interface VoicePlaybackButtonProps {
|
||||
characterId: string;
|
||||
userId: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
// P-6:語音播放介面——呼叫 /voice/:characterId/:userId/synthesize 取得合成結果後播放。
|
||||
// TTS_PROVIDER=mock 回傳的 audioRef 是 `mock://` 佔位字串(非真實音檔),改用計時器模擬播放時長;
|
||||
// 之後接上真實供應商、audioRef 變成可播放網址時走真正的 <audio> 播放,呼叫端不用改一行。
|
||||
// 合成失敗(網路或後端錯誤)時直接隱藏這顆按鈕,退回純文字顯示,不影響訊息本身、不阻斷對話。
|
||||
export function VoicePlaybackButton({ characterId, userId, text }: VoicePlaybackButtonProps) {
|
||||
const [state, setState] = useState<PlaybackState>("idle");
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
audioRef.current?.pause();
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
function stopPlayback() {
|
||||
audioRef.current?.pause();
|
||||
audioRef.current = null;
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
setState("idle");
|
||||
}
|
||||
|
||||
async function startPlayback() {
|
||||
setState("loading");
|
||||
try {
|
||||
const result = await synthesizeVoice(characterId, userId, text);
|
||||
if (result.audioRef.startsWith("mock://")) {
|
||||
setState("playing");
|
||||
timerRef.current = setTimeout(() => setState("idle"), Math.max(result.durationEstimateMs, 300));
|
||||
return;
|
||||
}
|
||||
const audio = new Audio(result.audioRef);
|
||||
audio.addEventListener("ended", () => setState("idle"));
|
||||
audio.addEventListener("error", () => setState("unavailable"));
|
||||
audioRef.current = audio;
|
||||
await audio.play();
|
||||
setState("playing");
|
||||
} catch {
|
||||
setState("unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
if (state === "unavailable") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (state === "playing" ? stopPlayback() : void startPlayback())}
|
||||
disabled={state === "loading"}
|
||||
aria-label={state === "playing" ? "停止播放語音" : "播放語音"}
|
||||
className="mt-1 inline-flex w-fit items-center gap-1 rounded-full px-2 py-0.5 text-[11px] disabled:opacity-50"
|
||||
style={{ color: "var(--accent)", background: "var(--chip-bg)" }}
|
||||
>
|
||||
{state === "loading" && "合成中…"}
|
||||
{state === "playing" && "■ 停止"}
|
||||
{state === "idle" && "▶ 播放語音"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user