Files
Kokorone/apps/mobile/app/room/[roomId].tsx
T
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

423 lines
16 KiB
TypeScript
Raw 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.
import { useEffect, useRef, useState } from "react";
import { FlatList, KeyboardAvoidingView, Platform, Pressable, StyleSheet, Text, TextInput, View } from "react-native";
import { useLocalSearchParams } from "expo-router";
import type { Character } from "@kokorone/shared";
import {
API_BASE_URL,
ROOM_USER_SPEAKER_ID,
endRoom,
fetchExpression,
getPossessionStatus,
getRoom,
getRoomUsage,
reportRoomRead,
sendRoomMessage,
setRoomMuted,
speakAsPossessed,
subscribeRoomStream,
synthesizeVoice,
type PossessionStatus,
type RoomDetail,
type RoomTurnKind,
type RoomUsageSummary,
type TachieExpressionResponse,
} from "../../lib/api";
import { RoomStage, type StageParticipant } from "../../components/RoomStage";
import { isVoiceAutoplayEnabled } from "../../lib/voice-autoplay-settings";
const USER_ID = "seed-user-primary";
const USER_LABEL = "你";
// T20-P3:與 apps/web 的 RoomChatView.tsx 對應——佇列積壓過長時只留最近幾則待播。
const VOICE_QUEUE_MAX_BACKLOG = 3;
interface VoiceQueueItem {
characterId: string;
text: string;
}
// T20-P3:與 apps/web 一致,ACTION 沿用既有 ChatBubble 的敘述樣式(此處以斜體呈現),其餘三種標籤。
const TURN_KIND_LABEL: Partial<Record<RoomTurnKind, string>> = {
DELEGATE: "委託",
LEAVE: "離開",
SYSTEM: "系統",
};
type FeedItem =
| { kind: "turn"; seq: number; speakerId: string; speakerLabel: string; content: string; turnKind: RoomTurnKind }
| { kind: "member"; seq: number; characterId: string; action: "joined" | "left" }
| { kind: "closed"; seq: number; reason: string };
// T20-P2:通用房間畫面——取代 T20-P1 唯讀最小可用版,改為可發言、以 fetch streaming(見
// lib/api.ts 的 subscribeRoomStream,非原生 EventSource)即時收新訊息、並依 unread(T11)畫出
// 第一則未讀分隔線,與 apps/web 的 app/room/[roomId] 對應同一版面與行為。
export default function RoomScreen() {
const { roomId } = useLocalSearchParams<{ roomId: string }>();
const [room, setRoom] = useState<RoomDetail | null>(null);
const [characters, setCharacters] = useState<Character[]>([]);
const [feed, setFeed] = useState<FeedItem[]>([]);
const [firstUnreadSeq, setFirstUnreadSeq] = useState<number | null>(null);
const [ended, setEnded] = useState(false);
const [text, setText] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const listRef = useRef<FlatList<FeedItem>>(null);
// T20-P5:套用中時,這個房間的輸入區要換成「以套用角色的身分發話」,不提供以自己身分發話的選項。
const [possession, setPossession] = useState<PossessionStatus | null>(null);
useEffect(() => {
void getPossessionStatus(USER_ID).then(setPossession).catch(() => undefined);
}, [roomId]);
// T20-P3:立繪舞台(目前發話者高亮)與用量顯示。
const [expressions, setExpressions] = useState<Record<string, TachieExpressionResponse | null>>({});
const [speakingCharacterId, setSpeakingCharacterId] = useState<string | null>(null);
const [usage, setUsage] = useState<RoomUsageSummary | null>(null);
// T20-P3:自走語音自動播放——與 apps/web 對應,但行動端無瀏覽器自動播放政策,不需要解鎖手勢;
// 佇列、播放中旗標一律用 ref,避免串流事件回呼讀到註冊當下就固定住的舊值(stale closure)。
const [autoplayEnabled, setAutoplayEnabled] = useState(true);
const autoplayEnabledRef = useRef(true);
const mutedRef = useRef(false);
const voiceQueueRef = useRef<VoiceQueueItem[]>([]);
const voicePlayingRef = useRef(false);
const voiceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const unmountedRef = useRef(false);
useEffect(() => {
void isVoiceAutoplayEnabled().then((value) => {
autoplayEnabledRef.current = value;
setAutoplayEnabled(value);
});
return () => {
unmountedRef.current = true;
if (voiceTimerRef.current) clearTimeout(voiceTimerRef.current);
voiceQueueRef.current = [];
};
}, []);
function refreshExpression(characterId: string) {
fetchExpression(characterId, USER_ID)
.then((expression) => setExpressions((prev) => ({ ...prev, [characterId]: expression })))
.catch(() => undefined);
}
function enqueueVoice(item: VoiceQueueItem) {
if (!autoplayEnabledRef.current || mutedRef.current) return;
voiceQueueRef.current.push(item);
if (voiceQueueRef.current.length > VOICE_QUEUE_MAX_BACKLOG) {
voiceQueueRef.current.splice(0, voiceQueueRef.current.length - VOICE_QUEUE_MAX_BACKLOG);
}
void processVoiceQueue();
}
async function processVoiceQueue() {
if (voicePlayingRef.current || unmountedRef.current) return;
const next = voiceQueueRef.current.shift();
if (!next) return;
voicePlayingRef.current = true;
try {
const result = await synthesizeVoice(next.characterId, USER_ID, next.text);
if (unmountedRef.current) return;
if (result.audioRef.startsWith("mock://")) {
await new Promise<void>((resolve) => {
voiceTimerRef.current = setTimeout(resolve, Math.max(result.durationEstimateMs, 300));
});
}
// T20-P3 已知限制:尚未安裝原生音訊播放套件(expo-audio/expo-av),真實音檔(非 mock://)
// 目前僅靜默略過、不會真的播放——見本項 wiki 完成註記;接上真實 TTS Provider 前需另外補上。
} catch {
// 合成失敗:安靜跳過這一則,不阻斷佇列後續播放。
} finally {
voicePlayingRef.current = false;
if (!unmountedRef.current) void processVoiceQueue();
}
}
useEffect(() => {
fetch(`${API_BASE_URL}/characters`)
.then((res) => res.json() as Promise<Character[]>)
.then(setCharacters)
.catch(() => undefined);
}, []);
function characterLabel(characterId: string): string {
return characters.find((c) => c.id === characterId)?.aliases?.[0]?.formalName ?? characterId;
}
async function handleToggleMute() {
if (!room || pending) return;
const next = !room.muted;
try {
await setRoomMuted(room.id, next);
mutedRef.current = next;
setRoom((prev) => (prev ? { ...prev, muted: next } : prev));
} catch {
setError("靜音設定失敗,請確認 api 服務是否已啟動");
}
}
useEffect(() => {
if (!roomId) return;
let cancelled = false;
let unsubscribe: (() => void) | null = null;
async function init() {
try {
const detail = await getRoom(roomId);
if (cancelled) return;
setRoom(detail);
mutedRef.current = detail.muted;
setEnded(detail.status === "ENDED");
setFirstUnreadSeq(detail.unread.firstUnreadSeq);
setUsage(detail.usage);
setFeed(
detail.turns.map((turn) => ({
kind: "turn",
seq: turn.seq,
speakerId: turn.speakerId,
speakerLabel: turn.speakerLabel,
content: turn.content,
turnKind: turn.kind,
})),
);
for (const characterId of detail.participants) refreshExpression(characterId);
const lastTurn = detail.turns.at(-1);
if (lastTurn && lastTurn.speakerId !== ROOM_USER_SPEAKER_ID) setSpeakingCharacterId(lastTurn.speakerId);
const lastSeq = detail.turns.length > 0 ? detail.turns[detail.turns.length - 1].seq : -1;
unsubscribe = subscribeRoomStream(roomId, lastSeq, (event) => {
if (event.type === "turn") {
const { kind: turnKind, ...rest } = event.data;
setFeed((prev) =>
prev.some((item) => item.kind === "turn" && item.seq === event.seq)
? prev
: [...prev, { kind: "turn", seq: event.seq, turnKind, ...rest }],
);
void reportRoomRead(roomId, USER_ID, event.seq).catch(() => undefined);
if (event.data.speakerId !== ROOM_USER_SPEAKER_ID) {
enqueueVoice({ characterId: event.data.speakerId, text: event.data.content });
setSpeakingCharacterId(event.data.speakerId);
refreshExpression(event.data.speakerId);
void getRoomUsage(roomId).then(setUsage).catch(() => undefined);
}
} else if (event.type === "member") {
setFeed((prev) => [...prev, { kind: "member", seq: event.seq, ...event.data }]);
} else if (event.type === "closed") {
setFeed((prev) => [...prev, { kind: "closed", seq: event.seq, reason: event.data.reason }]);
setEnded(true);
}
});
} catch {
if (!cancelled) setError("房間查詢失敗,請確認 api 服務是否已啟動");
}
}
void init();
return () => {
cancelled = true;
unsubscribe?.();
};
}, [roomId]);
// T20-P5:只有「套用中且她本人就在這個房間」才切換成以她的身分發話。
const possessedElsewhere = Boolean(possession?.possessingCharacterId) && !room?.participants.includes(possession!.possessingCharacterId!);
const possessedCharacterId =
possession?.possessingCharacterId && room?.participants.includes(possession.possessingCharacterId)
? possession.possessingCharacterId
: null;
async function handleSend() {
const trimmed = text.trim();
if (!trimmed || pending || ended || !roomId) return;
setPending(true);
setError(null);
try {
if (possessedCharacterId) {
await speakAsPossessed(roomId, USER_ID, trimmed);
} else {
await sendRoomMessage(roomId, USER_ID, USER_LABEL, trimmed);
}
setText(""); // 新訊息透過串流推送進 feed,這裡不自行 append
} catch (err) {
setError(err instanceof Error ? err.message : "發言失敗,請確認 api 服務是否已啟動");
} finally {
setPending(false);
}
}
async function handleEnd() {
if (pending || ended || !roomId) return;
setPending(true);
setError(null);
try {
await endRoom(roomId);
setEnded(true);
} catch {
setError("結束房間失敗,請確認 api 服務是否已啟動");
} finally {
setPending(false);
}
}
if (error && !room) {
return (
<View style={styles.container}>
<Text style={styles.error}>{error}</Text>
</View>
);
}
if (!room) {
return (
<View style={styles.container}>
<Text style={styles.hint}>載入中……</Text>
</View>
);
}
return (
<KeyboardAvoidingView style={styles.container} behavior={Platform.OS === "ios" ? "padding" : undefined}>
<View style={styles.headerRow}>
<Text style={styles.title}>
{room.mode}.{ended ? "已結束" : "進行中"}
{usage && usage.generationCount > 0 ? `.${usage.totalPromptTokens + usage.totalCompletionTokens} tokens` : ""}
</Text>
<View style={styles.headerButtons}>
<Pressable onPress={() => void handleToggleMute()} disabled={ended}>
<Text style={[styles.endButton, ended ? styles.endButtonDisabled : null]}>{room.muted ? "🔇 已靜音" : "🔊 靜音"}</Text>
</Pressable>
<Pressable onPress={handleEnd} disabled={pending || ended}>
<Text style={[styles.endButton, pending || ended ? styles.endButtonDisabled : null]}>結束房間</Text>
</Pressable>
</View>
</View>
<RoomStage
participants={room.participants.map<StageParticipant>((characterId) => ({
characterId,
label: characterLabel(characterId),
expression: expressions[characterId] ?? null,
}))}
speakingCharacterId={speakingCharacterId}
/>
<FlatList
ref={listRef}
style={styles.content}
contentContainerStyle={styles.contentInner}
data={feed}
keyExtractor={(item) => `${item.kind}-${item.seq}`}
onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: true })}
ListEmptyComponent={<Text style={styles.hint}>還沒有人說話。</Text>}
renderItem={({ item }) => (
<View style={styles.feedItem}>
{firstUnreadSeq !== null && item.seq === firstUnreadSeq && (
<View style={styles.dividerRow}>
<View style={styles.dividerLine} />
<Text style={styles.dividerText}>未讀訊息</Text>
<View style={styles.dividerLine} />
</View>
)}
{item.kind === "turn" && (
<View style={{ alignSelf: item.speakerId === ROOM_USER_SPEAKER_ID ? "flex-end" : "flex-start" }}>
{TURN_KIND_LABEL[item.turnKind] && <Text style={styles.turnKindBadge}>{TURN_KIND_LABEL[item.turnKind]}</Text>}
<View style={[styles.bubble, item.speakerId === ROOM_USER_SPEAKER_ID ? styles.bubbleUser : styles.bubbleCharacter]}>
<Text style={styles.speakerLabel}>{item.speakerLabel}</Text>
<Text style={[styles.bubbleText, item.turnKind === "ACTION" ? styles.actionText : null]}>{item.content}</Text>
</View>
</View>
)}
{item.kind === "member" && (
<Text style={styles.systemNote}>
{characterLabel(item.characterId)} {item.action === "joined" ? "進來了" : "離開了"}
</Text>
)}
{item.kind === "closed" && <Text style={styles.systemNote}>房間已關閉</Text>}
</View>
)}
/>
{error && <Text style={styles.error}>{error}</Text>}
{!ended && possessedCharacterId && (
<Text style={styles.possessionNotice}>你正以「{characterLabel(possessedCharacterId)}」的身分發話</Text>
)}
{!ended && possessedElsewhere && (
<Text style={styles.hint}>套用中的角色不在這個房間,無法在此發話——請到她所在的房間,或先退出套用。</Text>
)}
{!ended && !possessedElsewhere && (
<View style={styles.inputRow}>
<TextInput
value={text}
onChangeText={setText}
placeholder={possessedCharacterId ? `以「${characterLabel(possessedCharacterId)}」的身分說點什麼……` : "說點什麼……"}
style={styles.input}
editable={!pending}
onSubmitEditing={handleSend}
/>
<Pressable onPress={handleSend} disabled={pending || !text.trim()}>
<Text style={[styles.sendButton, pending || !text.trim() ? styles.sendButtonDisabled : null]}>送出</Text>
</Pressable>
</View>
)}
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: "#FFF5F7" },
headerRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 16,
paddingTop: 12,
},
title: { fontSize: 14, fontWeight: "bold", color: "#FF7E9D", flexShrink: 1 },
headerButtons: { flexDirection: "row", alignItems: "center", gap: 12 },
endButton: { fontSize: 12, color: "#8a6b6f" },
endButtonDisabled: { opacity: 0.5 },
content: { flex: 1 },
contentInner: { padding: 16, gap: 8 },
feedItem: { gap: 6 },
dividerRow: { flexDirection: "row", alignItems: "center", gap: 8 },
dividerLine: { flex: 1, height: 1, backgroundColor: "#FF7E9D" },
dividerText: { fontSize: 10, color: "#FF7E9D" },
hint: { fontSize: 12, color: "#8a6b6f", padding: 16 },
error: { fontSize: 12, color: "#FF7E9D", paddingHorizontal: 16 },
possessionNotice: { fontSize: 11, fontWeight: "bold", color: "#FF7E9D", textAlign: "center", paddingVertical: 4 },
bubble: { maxWidth: "80%", borderRadius: 16, padding: 10, gap: 2 },
bubbleUser: { alignSelf: "flex-end", backgroundColor: "#FF7E9D" },
bubbleCharacter: { alignSelf: "flex-start", backgroundColor: "#EFEFEF" },
speakerLabel: { fontSize: 10, color: "#8a6b6f" },
bubbleText: { color: "#222" },
actionText: { fontStyle: "italic", color: "#9B8CFF" },
turnKindBadge: {
alignSelf: "flex-start",
fontSize: 10,
color: "#FF7E9D",
backgroundColor: "#FFE3EA",
borderRadius: 999,
paddingHorizontal: 8,
paddingVertical: 1,
marginBottom: 2,
},
systemNote: { alignSelf: "center", fontSize: 10, color: "#8a6b6f" },
inputRow: {
flexDirection: "row",
alignItems: "center",
gap: 8,
margin: 12,
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 999,
borderWidth: 1,
borderColor: "#E5D5D8",
},
input: { flex: 1, fontSize: 14 },
sendButton: { color: "#FF7E9D", fontWeight: "bold" },
sendButtonDisabled: { opacity: 0.5 },
});