新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、 時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙 測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
162 lines
5.3 KiB
TypeScript
162 lines
5.3 KiB
TypeScript
"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>
|
|
);
|
|
}
|