feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能
新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、 時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙 測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,422 @@
|
||||
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 },
|
||||
});
|
||||
Reference in New Issue
Block a user