Files
Kokorone/apps/web/app/room/solo/SoloChatView.tsx
jiantw83andClaude Sonnet 5 1bea06e3cd feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能
新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、
時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙
測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 09:10:26 +08:00

169 lines
6.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useState } from "react";
import { getOrCreateSoloRoom, sendSoloMessage, fetchExpression, type TachieExpressionResponse } from "../../../lib/api";
import { EMOTION_LABELS } from "../../../lib/emotion-labels";
import { ChatBubble } from "../../../components/ChatBubble";
import { HeartbeatWave } from "../../../components/HeartbeatWave";
import { TachieStage } from "../../../components/TachieStage";
import { RomanceSection } from "../../../components/RomanceSection";
import type { EmotionTag } from "@kokorone/shared";
// O-2 表情演出時機:表情差分要在文字回覆出現前先變化——順序要對才有真實感。
const EXPRESSION_LEAD_MS = 220;
const CHARACTER_ID = "seed-character-genki";
const USER_ID = "seed-user-primary";
interface Message {
role: "user" | "character";
text: string;
}
// T38:既有 1:1 聊天併入 SOLO 房——原 apps/web/app/chat/ChatView.tsx 的內容搬移至此,
// 呼叫端改用房間端點(getOrCreateSoloRoom/sendSoloMessage)取代舊版 sessionId 自行管理。
export function SoloChatView() {
const [roomId, setRoomId] = useState<string | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
const [dominant, setDominant] = useState<EmotionTag>("CALM");
const [intimacy, setIntimacy] = useState(0);
const [expression, setExpression] = useState<TachieExpressionResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [romanceRefreshKey, setRomanceRefreshKey] = useState(0);
useEffect(() => {
getOrCreateSoloRoom(USER_ID, CHARACTER_ID)
.then((room) => setRoomId(room.id))
.catch(() => setError("房間建立失敗,請確認 api 服務是否已啟動"));
}, []);
async function handleSend() {
const text = input.trim();
if (!text || sending || !roomId) {
return;
}
setSending(true);
setError(null);
setInput("");
setMessages((prev) => [...prev, { role: "user", text }]);
try {
const result = await sendSoloMessage(roomId, USER_ID, text);
if (result.availability === "NO_REPLY") {
// I-2:作息狀態為睡眠且未破例時,這輪不會有真的回覆。
return;
}
if (result.context) {
setDominant(result.context.emotion.dominant);
setIntimacy(result.context.relationship.intimacy);
}
// O-2:情緒引擎在文字生成前就已經更新完畢,這裡先套用新表情,文字訊息延後一小段時間才出現。
try {
setExpression(await fetchExpression(CHARACTER_ID, USER_ID));
} catch {
// 表情查詢失敗不影響對話本身。
}
await new Promise((resolve) => setTimeout(resolve, EXPRESSION_LEAD_MS));
setMessages((prev) => [...prev, { role: "character", text: result.text }]);
// L-9:心動值與戀愛階段隨事件即時更新——每輪對話後都重新查詢一次。
setRomanceRefreshKey((key) => key + 1);
} catch {
setError("訊息傳送失敗,請確認 api 服務是否已啟動");
} finally {
setSending(false);
}
}
return (
<div className="flex h-dvh flex-col md:flex-row">
<div
className="relative flex h-[38vh] shrink-0 items-end justify-center overflow-hidden md:order-2 md:h-auto md:w-[38%] md:min-w-[280px]"
style={{ background: "linear-gradient(180deg, var(--bg-soft), var(--card))" }}
>
<span className="absolute top-3.5 left-3.5 text-[11px]" style={{ color: "var(--ink-soft)" }}>
親密度 <b style={{ color: "var(--accent)" }}>{intimacy}</b>
</span>
<span
className="absolute top-3.5 right-3.5 rounded-full px-3 py-1 text-xs font-bold"
style={{ background: "var(--chip-bg)", color: "var(--accent)" }}
>
{EMOTION_LABELS[dominant]}
</span>
<div className="mb-6 flex flex-col items-center gap-2">
<TachieStage expression={expression} />
<p className="text-xs" style={{ color: "var(--ink-soft)" }}>
立繪(表情差分即時變化)
</p>
</div>
</div>
<div
className="flex flex-1 flex-col gap-2.5 overflow-y-auto p-4 md:order-1 md:border-r"
style={{ borderColor: "var(--line)" }}
>
<div className="mb-2 flex items-center gap-3">
<HeartbeatWave amplitude={intimacy} className="h-6 w-24" />
<h1 className="text-sm font-bold" style={{ color: "var(--accent)" }}>
心音 Kokorone
</h1>
</div>
<div className="flex flex-1 flex-col gap-2.5 overflow-y-auto">
{messages.length === 0 && (
<p className="text-sm" style={{ color: "var(--ink-soft)" }}>
傳個訊息,開始今天的對話吧。
</p>
)}
{messages.map((message, index) => (
<ChatBubble
key={index}
role={message.role}
text={message.text}
characterId={CHARACTER_ID}
userId={USER_ID}
/>
))}
</div>
{error && (
<p className="text-xs" style={{ color: "var(--accent)" }}>
{error}
</p>
)}
<RomanceSection characterId={CHARACTER_ID} userId={USER_ID} refreshKey={romanceRefreshKey} />
<div
className="mt-2 flex items-center justify-between gap-2 rounded-full border px-4 py-2 text-sm"
style={{ borderColor: "var(--line)" }}
>
<input
value={input}
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
void handleSend();
}
}}
placeholder="輸入訊息……"
className="flex-1 bg-transparent outline-none"
style={{ color: "var(--ink)" }}
disabled={sending}
/>
<button
type="button"
onClick={() => void handleSend()}
disabled={sending}
className="font-bold disabled:opacity-50"
style={{ color: "var(--accent)" }}
>
送出
</button>
</div>
</div>
</div>
);
}