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,42 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Animated, Easing, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { useReduceMotion } from "../lib/reduce-motion";
|
||||
|
||||
export interface HeartbeatDotProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
// Q-4:未讀/推播提示紅點=小心跳,對應 apps/web 的 `.heartbeat-dot`(1.2s 循環的 opacity+scale 脈動)。
|
||||
export function HeartbeatDot({ size = 10, color = "#FF7E9D", style }: HeartbeatDotProps) {
|
||||
const reduceMotion = useReduceMotion();
|
||||
const t = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (reduceMotion) {
|
||||
t.setValue(0);
|
||||
return;
|
||||
}
|
||||
const loop = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(t, { toValue: 1, duration: 600, easing: Easing.inOut(Easing.ease), useNativeDriver: true }),
|
||||
Animated.timing(t, { toValue: 0, duration: 600, easing: Easing.inOut(Easing.ease), useNativeDriver: true }),
|
||||
]),
|
||||
);
|
||||
loop.start();
|
||||
return () => loop.stop();
|
||||
}, [reduceMotion, t]);
|
||||
|
||||
const scale = t.interpolate({ inputRange: [0, 1], outputRange: [1, 1.3] });
|
||||
const opacity = t.interpolate({ inputRange: [0, 1], outputRange: [1, 0.7] });
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
{ width: size, height: size, borderRadius: size / 2, backgroundColor: color, opacity, transform: [{ scale }] },
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { Animated, Easing, type StyleProp, type ViewStyle } from "react-native";
|
||||
import Svg, { Polyline } from "react-native-svg";
|
||||
import { useReduceMotion } from "../lib/reduce-motion";
|
||||
|
||||
const WIDTH = 400;
|
||||
const MID_Y = 50;
|
||||
const DASH_LENGTH = 600;
|
||||
|
||||
// 與 apps/web/components/HeartbeatWave.tsx 相同的波形算法,維持三端視覺一致;
|
||||
// 親密度越高,波峰振幅越大。
|
||||
function buildPoints(amplitude: number): string {
|
||||
const clamped = Math.max(0, Math.min(100, amplitude));
|
||||
const scale = 0.4 + (clamped / 100) * 1.1;
|
||||
const up1 = MID_Y - 15 * scale;
|
||||
const down = MID_Y + 26 * scale;
|
||||
const up2 = MID_Y - 22 * scale;
|
||||
|
||||
const points: Array<[number, number]> = [
|
||||
[0, MID_Y],
|
||||
[150, MID_Y],
|
||||
[168, MID_Y],
|
||||
[178, up1],
|
||||
[188, down],
|
||||
[198, up2],
|
||||
[208, MID_Y],
|
||||
[WIDTH, MID_Y],
|
||||
];
|
||||
|
||||
return points.map(([x, y]) => `${x},${y.toFixed(1)}`).join(" ");
|
||||
}
|
||||
|
||||
const AnimatedPolyline = Animated.createAnimatedComponent(Polyline);
|
||||
|
||||
export interface HeartbeatWaveProps {
|
||||
amplitude?: number; // 0~100,對應親密度
|
||||
animateOnMount?: boolean; // 是否播放「由左至右畫出」的載入動畫
|
||||
color?: string;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
export function HeartbeatWave({ amplitude = 50, animateOnMount = true, color = "#FF7E9D", style }: HeartbeatWaveProps) {
|
||||
const reduceMotion = useReduceMotion();
|
||||
const shouldAnimate = animateOnMount && !reduceMotion;
|
||||
const dashOffset = useRef(new Animated.Value(shouldAnimate ? DASH_LENGTH : 0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldAnimate) {
|
||||
dashOffset.setValue(0);
|
||||
return;
|
||||
}
|
||||
dashOffset.setValue(DASH_LENGTH);
|
||||
Animated.timing(dashOffset, {
|
||||
toValue: 0,
|
||||
duration: 1600,
|
||||
easing: Easing.out(Easing.ease),
|
||||
// strokeDashoffset 不在原生驅動支援的屬性清單內,只能走 JS 驅動。
|
||||
useNativeDriver: false,
|
||||
}).start();
|
||||
}, [shouldAnimate, dashOffset]);
|
||||
|
||||
const points = useMemo(() => buildPoints(amplitude), [amplitude]);
|
||||
|
||||
return (
|
||||
<Svg viewBox={`0 0 ${WIDTH} 100`} style={style}>
|
||||
<AnimatedPolyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={3}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeDasharray={DASH_LENGTH}
|
||||
strokeDashoffset={dashOffset}
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import {
|
||||
confirmEndingDetection,
|
||||
decideInvitation,
|
||||
decideLobbyCall,
|
||||
enterPossession,
|
||||
exitPossession,
|
||||
getFullLobby,
|
||||
getPossessionStatus,
|
||||
listEndingDetections,
|
||||
listPendingAdjudication,
|
||||
listPendingInvitations,
|
||||
listPendingLobbyCalls,
|
||||
rejectEndingDetection,
|
||||
sayToLobby,
|
||||
type EndingDetectionLog,
|
||||
type LobbyFullSnapshot,
|
||||
type LobbyPresenceState,
|
||||
type PendingInvitation,
|
||||
type PendingLobbyCall,
|
||||
type PossessionStatus,
|
||||
} from "../lib/api";
|
||||
import { useUnread } from "../lib/unread-context";
|
||||
|
||||
const USER_ID = "seed-user-primary";
|
||||
|
||||
const PRESENCE_LABELS: Record<LobbyPresenceState, string> = {
|
||||
BUILDING: "建置中",
|
||||
SLEEPING: "睡眠中",
|
||||
BUSY: "忙碌中",
|
||||
SEMI_BUSY: "略忙",
|
||||
IDLE: "空閒",
|
||||
IN_SOLO_ROOM: "單獨聊天中",
|
||||
IN_GROUP_ROOM: "多人房中",
|
||||
};
|
||||
|
||||
// T20-P2:大廳頁完整版,取代 T20-P1 最小快照(在場名單+房間清單),改用 T4 正式聚合端點,
|
||||
// 與 apps/web 的 LobbyView 對應同一版面:進行中房間(含未讀)→ 作品分組角色卡片 → 大廳對話輸入。
|
||||
export function LobbyPreview() {
|
||||
const router = useRouter();
|
||||
const { setRoomUnreadCounts } = useUnread();
|
||||
const [snapshot, setSnapshot] = useState<LobbyFullSnapshot | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [text, setText] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [lobbyMessage, setLobbyMessage] = useState<string | null>(null);
|
||||
const [pendingAdjudicationByWork, setPendingAdjudicationByWork] = useState<Record<string, number>>({});
|
||||
const [endingDetectionsByWork, setEndingDetectionsByWork] = useState<Record<string, EndingDetectionLog[]>>({});
|
||||
const [possession, setPossession] = useState<PossessionStatus | null>(null);
|
||||
const [pendingInvitations, setPendingInvitations] = useState<PendingInvitation[]>([]);
|
||||
const [pendingCalls, setPendingCalls] = useState<PendingLobbyCall[]>([]);
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const [lobby, possessionStatus] = await Promise.all([getFullLobby(USER_ID), getPossessionStatus(USER_ID)]);
|
||||
setSnapshot(lobby);
|
||||
setPossession(possessionStatus);
|
||||
setError(null);
|
||||
// T20-P2:把各房未讀數餵給 UnreadContext,聊天分頁籤的紅點才能反映「大廳裡有房間未讀」。
|
||||
setRoomUnreadCounts(Object.fromEntries(lobby.activeRooms.map((room) => [room.id, room.unreadCount])));
|
||||
} catch {
|
||||
setError("大廳查詢失敗,請確認 api 服務是否已啟動");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// T20-P5:套用中時查詢待決邀請與待決大廳呼叫,與 apps/web 的 LobbyView 對應同一批次查詢方式。
|
||||
useEffect(() => {
|
||||
if (!possession?.possessingCharacterId) {
|
||||
setPendingInvitations([]);
|
||||
setPendingCalls([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void Promise.all([listPendingInvitations(USER_ID), listPendingLobbyCalls(USER_ID)]).then(([invitations, calls]) => {
|
||||
if (cancelled) return;
|
||||
setPendingInvitations(invitations);
|
||||
setPendingCalls(calls);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [possession]);
|
||||
|
||||
async function handleEnterPossession(characterId: string) {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await enterPossession(USER_ID, characterId);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "套用失敗,請確認 api 服務是否已啟動");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExitPossession() {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await exitPossession(USER_ID);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "退出套用失敗,請確認 api 服務是否已啟動");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDecideInvitation(invitationId: string, accept: boolean) {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await decideInvitation(invitationId, accept);
|
||||
setPendingInvitations(await listPendingInvitations(USER_ID));
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "邀請決定失敗,請確認 api 服務是否已啟動");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDecideCall(pendingCallId: string, accept: boolean) {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const decision = await decideLobbyCall(pendingCallId, accept);
|
||||
setPendingCalls(await listPendingLobbyCalls(USER_ID));
|
||||
if (decision.roomId) {
|
||||
router.push(`/room/${decision.roomId}`);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "呼叫決定失敗,請確認 api 服務是否已啟動");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
// T20-P4:作品卡片標題列的「有待裁決項目」提示與結局偵測待確認清單,與 apps/web 的
|
||||
// LobbyView 對應同一批次查詢方式,snapshot 每次刷新都會連動重新取得。
|
||||
useEffect(() => {
|
||||
if (!snapshot) return;
|
||||
const workIds = snapshot.works.map((w) => w.id);
|
||||
let cancelled = false;
|
||||
void Promise.all(
|
||||
workIds.map(async (workId) => {
|
||||
const [adjudication, endings] = await Promise.all([listPendingAdjudication(workId), listEndingDetections(workId)]);
|
||||
return { workId, pendingCount: adjudication.length, endings };
|
||||
}),
|
||||
).then((results) => {
|
||||
if (cancelled) return;
|
||||
setPendingAdjudicationByWork(Object.fromEntries(results.map((r) => [r.workId, r.pendingCount])));
|
||||
setEndingDetectionsByWork(Object.fromEntries(results.map((r) => [r.workId, r.endings])));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [snapshot]);
|
||||
|
||||
async function handleConfirmEnding(logId: string) {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await confirmEndingDetection(logId);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "結局確認失敗,請確認 api 服務是否已啟動");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRejectEnding(logId: string) {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await rejectEndingDetection(logId);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "結局拒絕失敗,請確認 api 服務是否已啟動");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSay() {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || pending || possession?.possessingCharacterId) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
setLobbyMessage(null);
|
||||
try {
|
||||
const result = await sayToLobby(USER_ID, trimmed);
|
||||
setText("");
|
||||
if (result.roomId) {
|
||||
router.push(`/room/${result.roomId}`);
|
||||
return;
|
||||
}
|
||||
if (result.message) {
|
||||
setLobbyMessage(result.message);
|
||||
}
|
||||
await refresh();
|
||||
} catch {
|
||||
setError("大廳呼叫失敗,請確認 api 服務是否已啟動");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Text style={styles.error}>{error}</Text>;
|
||||
}
|
||||
if (!snapshot) {
|
||||
return <Text style={styles.hint}>大廳載入中……</Text>;
|
||||
}
|
||||
|
||||
const workGroups = snapshot.works.map((work) => ({
|
||||
work,
|
||||
characters: snapshot.characters.filter((c) => c.workId === work.id),
|
||||
}));
|
||||
const unassigned = snapshot.characters.filter((c) => c.workId === null);
|
||||
const possessedCharacterId = possession?.possessingCharacterId ?? null;
|
||||
const possessedCharacterName = possessedCharacterId
|
||||
? snapshot.characters.find((c) => c.id === possessedCharacterId)?.formalName ?? possessedCharacterId
|
||||
: null;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{possessedCharacterId && (
|
||||
<View style={styles.possessionBanner}>
|
||||
<View style={styles.possessionBannerRow}>
|
||||
<Text style={styles.possessionBannerTitle}>你現在是「{possessedCharacterName}」</Text>
|
||||
<Pressable onPress={() => void handleExitPossession()} disabled={pending}>
|
||||
<Text style={[styles.exitPossessionLink, pending ? styles.sendButtonDisabled : null]}>退出套用</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<Text style={styles.possessionBannerHint}>她的自走已暫停;請到她所在的房間以她的身分發話,或在下方決定邀請與呼叫。</Text>
|
||||
|
||||
{pendingCalls.map((call) => (
|
||||
<View key={call.id} style={styles.endingCard}>
|
||||
<Text style={styles.endingCardTitle}>有人喊了你:「{call.text}」</Text>
|
||||
<View style={styles.endingCardActions}>
|
||||
<Pressable onPress={() => void handleDecideCall(call.id, true)} disabled={pending}>
|
||||
<Text style={[styles.confirmButton, pending ? styles.sendButtonDisabled : null]}>接受</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => void handleDecideCall(call.id, false)} disabled={pending}>
|
||||
<Text style={[styles.rejectButton, pending ? styles.sendButtonDisabled : null]}>婉拒</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{pendingInvitations.map((invitation) => (
|
||||
<View key={invitation.id} style={styles.endingCard}>
|
||||
<Text style={styles.endingCardTitle}>
|
||||
{(invitation.invitedByCharacterId &&
|
||||
snapshot.characters.find((c) => c.id === invitation.invitedByCharacterId)?.formalName) ??
|
||||
"有人"}
|
||||
邀請你到房間裡聊聊
|
||||
</Text>
|
||||
<View style={styles.endingCardActions}>
|
||||
<Pressable onPress={() => void handleDecideInvitation(invitation.id, true)} disabled={pending}>
|
||||
<Text style={[styles.confirmButton, pending ? styles.sendButtonDisabled : null]}>接受</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => void handleDecideInvitation(invitation.id, false)} disabled={pending}>
|
||||
<Text style={[styles.rejectButton, pending ? styles.sendButtonDisabled : null]}>婉拒</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text style={styles.sectionTitle}>進行中房間({snapshot.activeRooms.length})</Text>
|
||||
{snapshot.activeRooms.length === 0 ? (
|
||||
<Text style={styles.hint}>目前沒有進行中的房間。</Text>
|
||||
) : (
|
||||
snapshot.activeRooms.map((room) => (
|
||||
<Pressable key={room.id} style={styles.roomCard} onPress={() => router.push(`/room/${room.id}`)}>
|
||||
<View style={styles.roomCardRow}>
|
||||
<Text style={styles.roomCardText}>
|
||||
{room.memberCharacterIds
|
||||
.map((id) => snapshot.characters.find((c) => c.id === id)?.formalName ?? id)
|
||||
.join("、") || room.mode}
|
||||
</Text>
|
||||
{room.unreadCount > 0 && (
|
||||
<View style={styles.unreadBadge}>
|
||||
<Text style={styles.unreadBadgeText}>{room.unreadCount}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
))
|
||||
)}
|
||||
|
||||
{workGroups.map(({ work, characters }) => {
|
||||
if (characters.length === 0) return null;
|
||||
const pendingCount = pendingAdjudicationByWork[work.id] ?? 0;
|
||||
const endings = endingDetectionsByWork[work.id] ?? [];
|
||||
return (
|
||||
<View key={work.id} style={styles.workSection}>
|
||||
<View style={styles.workHeaderRow}>
|
||||
<Text style={styles.sectionTitle}>
|
||||
{work.title}
|
||||
{work.inEpilogue ? "(後日談)" : ""}
|
||||
</Text>
|
||||
<View style={styles.workHeaderActions}>
|
||||
{pendingCount > 0 && (
|
||||
<Pressable style={styles.pendingBadge} onPress={() => router.push("/canon-review")}>
|
||||
<Text style={styles.pendingBadgeText}>有 {pendingCount} 項待裁決</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable style={styles.importChip} onPress={() => router.push(`/canon-import?workId=${work.id}`)}>
|
||||
<Text style={styles.importChipText}>匯入章節</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.rosterRow}>
|
||||
{characters.map((character) => (
|
||||
<View key={character.id} style={styles.chip}>
|
||||
<Text style={styles.chipText}>
|
||||
{character.formalName}/{PRESENCE_LABELS[character.presenceState]}
|
||||
</Text>
|
||||
{character.id === possessedCharacterId ? (
|
||||
<View style={styles.possessedBadge}>
|
||||
<Text style={styles.possessedBadgeText}>套用中</Text>
|
||||
</View>
|
||||
) : (
|
||||
!possessedCharacterId && (
|
||||
<Pressable onPress={() => void handleEnterPossession(character.id)} disabled={pending} style={styles.possessButton}>
|
||||
<Text style={styles.possessButtonText}>套用</Text>
|
||||
</Pressable>
|
||||
)
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{endings.map((ending) => (
|
||||
<View key={ending.id} style={styles.endingCard}>
|
||||
<Text style={styles.endingCardTitle}>偵測到可能的結局章節,是否切入後日談?</Text>
|
||||
<Text style={styles.endingCardEvidence}>判定理由:{ending.evidence}</Text>
|
||||
<View style={styles.endingCardActions}>
|
||||
<Pressable onPress={() => void handleConfirmEnding(ending.id)} disabled={pending}>
|
||||
<Text style={[styles.confirmButton, pending ? styles.sendButtonDisabled : null]}>確認.切入後日談</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => void handleRejectEnding(ending.id)} disabled={pending}>
|
||||
<Text style={[styles.rejectButton, pending ? styles.sendButtonDisabled : null]}>拒絕</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{work.milestones.length > 0 && (
|
||||
<View style={styles.milestoneList}>
|
||||
{work.milestones.map((milestone, index) => (
|
||||
<Text key={`${milestone.characterId}-${index}`} style={styles.milestoneText}>
|
||||
.{characters.find((c) => c.id === milestone.characterId)?.formalName ?? milestone.characterId}:
|
||||
{milestone.label}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
|
||||
{unassigned.length > 0 && (
|
||||
<View style={styles.workSection}>
|
||||
<Text style={styles.sectionTitle}>未分組角色</Text>
|
||||
<View style={styles.rosterRow}>
|
||||
{unassigned.map((character) => (
|
||||
<View key={character.id} style={styles.chip}>
|
||||
<Text style={styles.chipText}>
|
||||
{character.formalName}/{PRESENCE_LABELS[character.presenceState]}
|
||||
</Text>
|
||||
{character.id === possessedCharacterId ? (
|
||||
<View style={styles.possessedBadge}>
|
||||
<Text style={styles.possessedBadgeText}>套用中</Text>
|
||||
</View>
|
||||
) : (
|
||||
!possessedCharacterId && (
|
||||
<Pressable onPress={() => void handleEnterPossession(character.id)} disabled={pending} style={styles.possessButton}>
|
||||
<Text style={styles.possessButtonText}>套用</Text>
|
||||
</Pressable>
|
||||
)
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{lobbyMessage && (
|
||||
<View style={styles.messageBox}>
|
||||
<Text style={styles.messageBoxText}>{lobbyMessage}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{possessedCharacterId ? (
|
||||
<Text style={styles.hint}>套用中無法以自己的身分喊人,請先退出套用。</Text>
|
||||
) : (
|
||||
<View style={styles.sayRow}>
|
||||
<TextInput
|
||||
value={text}
|
||||
onChangeText={setText}
|
||||
placeholder="喊一下誰的名字……"
|
||||
style={styles.sayInput}
|
||||
editable={!pending}
|
||||
onSubmitEditing={handleSay}
|
||||
/>
|
||||
<Pressable onPress={handleSay} disabled={pending || !text.trim()}>
|
||||
<Text style={[styles.sendButton, pending || !text.trim() ? styles.sendButtonDisabled : null]}>送出</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { paddingHorizontal: 16, paddingTop: 12, paddingBottom: 12, gap: 6 },
|
||||
sectionTitle: { fontSize: 12, fontWeight: "bold", color: "#8a6b6f", marginTop: 6 },
|
||||
rosterRow: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
|
||||
workSection: { gap: 6 },
|
||||
workHeaderRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 6 },
|
||||
workHeaderActions: { flexDirection: "row", alignItems: "center", gap: 6 },
|
||||
pendingBadge: { borderRadius: 999, backgroundColor: "#FF7E9D", paddingHorizontal: 8, paddingVertical: 3 },
|
||||
pendingBadgeText: { fontSize: 10, fontWeight: "bold", color: "#fff" },
|
||||
importChip: { borderRadius: 999, borderWidth: 1, borderColor: "#E5D5D8", paddingHorizontal: 8, paddingVertical: 3 },
|
||||
importChipText: { fontSize: 10, color: "#8a6b6f" },
|
||||
endingCard: { borderRadius: 12, borderWidth: 1, borderColor: "#E5D5D8", padding: 8, gap: 4 },
|
||||
endingCardTitle: { fontSize: 11, color: "#222" },
|
||||
endingCardEvidence: { fontSize: 11, color: "#8a6b6f" },
|
||||
endingCardActions: { flexDirection: "row", gap: 12 },
|
||||
confirmButton: { fontSize: 11, fontWeight: "bold", color: "#FF7E9D" },
|
||||
rejectButton: { fontSize: 11, color: "#8a6b6f" },
|
||||
milestoneList: { gap: 2 },
|
||||
milestoneText: { fontSize: 11, color: "#8a6b6f" },
|
||||
chip: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
borderRadius: 999,
|
||||
borderWidth: 1,
|
||||
borderColor: "#E5D5D8",
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
possessedBadge: { borderRadius: 999, backgroundColor: "#FF7E9D", paddingHorizontal: 6, paddingVertical: 1 },
|
||||
possessedBadgeText: { fontSize: 9, fontWeight: "bold", color: "#fff" },
|
||||
possessButton: { borderRadius: 999, borderWidth: 1, borderColor: "#E5D5D8", paddingHorizontal: 6, paddingVertical: 1 },
|
||||
possessButtonText: { fontSize: 9, color: "#8a6b6f" },
|
||||
possessionBanner: { borderRadius: 12, borderWidth: 2, borderColor: "#FF7E9D", backgroundColor: "#FFE1E9", padding: 10, gap: 6 },
|
||||
possessionBannerRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
|
||||
possessionBannerTitle: { fontSize: 13, fontWeight: "bold", color: "#FF7E9D" },
|
||||
possessionBannerHint: { fontSize: 11, color: "#8a6b6f" },
|
||||
exitPossessionLink: { fontSize: 12, fontWeight: "bold", color: "#FF7E9D" },
|
||||
chipText: { fontSize: 12, color: "#222" },
|
||||
roomCard: {
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: "#E5D5D8",
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
roomCardRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
|
||||
roomCardText: { fontSize: 12, color: "#222" },
|
||||
unreadBadge: {
|
||||
backgroundColor: "#FF7E9D",
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 7,
|
||||
paddingVertical: 2,
|
||||
minWidth: 18,
|
||||
alignItems: "center",
|
||||
},
|
||||
unreadBadgeText: { fontSize: 10, fontWeight: "bold", color: "#fff" },
|
||||
messageBox: {
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: "#E5D5D8",
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
messageBoxText: { fontSize: 12, color: "#222" },
|
||||
sayRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
marginTop: 8,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 999,
|
||||
borderWidth: 1,
|
||||
borderColor: "#E5D5D8",
|
||||
},
|
||||
sayInput: { flex: 1, fontSize: 14 },
|
||||
sendButton: { color: "#FF7E9D", fontWeight: "bold" },
|
||||
sendButtonDisabled: { opacity: 0.5 },
|
||||
hint: { fontSize: 12, color: "#8a6b6f", paddingHorizontal: 16 },
|
||||
error: { fontSize: 12, color: "#FF7E9D", paddingHorizontal: 16 },
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { TachieStage, type TachieExpression } from "./TachieStage";
|
||||
import { HeartbeatDot } from "./HeartbeatDot";
|
||||
|
||||
export interface StageParticipant {
|
||||
characterId: string;
|
||||
label: string;
|
||||
expression: TachieExpression | null;
|
||||
}
|
||||
|
||||
interface RoomStageProps {
|
||||
participants: StageParticipant[];
|
||||
speakingCharacterId: string | null;
|
||||
}
|
||||
|
||||
// T20-P3:多人房立繪舞台——與 apps/web 的 components/RoomStage.tsx 呈現同等資訊,目前發話者
|
||||
// 用 HeartbeatDot(Q-4 既有的心跳提示元件)標示,人多時整體縮小排列。
|
||||
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 (
|
||||
<ScrollView horizontal contentContainerStyle={styles.row} showsHorizontalScrollIndicator={false}>
|
||||
{participants.map((participant) => {
|
||||
const speaking = participant.characterId === speakingCharacterId;
|
||||
return (
|
||||
<View key={participant.characterId} style={[styles.item, { transform: [{ scale }], opacity: speakingCharacterId && !speaking ? 0.55 : 1 }]}>
|
||||
{speaking && <HeartbeatDot size={8} style={styles.dot} />}
|
||||
<TachieStage expression={participant.expression} />
|
||||
<Text style={[styles.label, speaking ? styles.labelSpeaking : null]}>{participant.label}</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: { flexDirection: "row", alignItems: "flex-end", gap: 8, paddingHorizontal: 12, paddingVertical: 4 },
|
||||
item: { alignItems: "center", gap: 2 },
|
||||
dot: { marginBottom: 2 },
|
||||
label: { fontSize: 10, color: "#8a6b6f" },
|
||||
labelSpeaking: { color: "#FF7E9D", fontWeight: "bold" },
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
import { Canvas, Group, Oval, Path, Rect, RoundedRect } from "@shopify/react-native-skia";
|
||||
import type { EmotionTag } from "@kokorone/shared";
|
||||
|
||||
export type GazeDirection = "UP" | "AWAY" | "DIRECT" | "NEUTRAL";
|
||||
|
||||
export interface TachieExpression {
|
||||
emotionTag: EmotionTag;
|
||||
eyebrow: string;
|
||||
eye: string;
|
||||
mouth: string;
|
||||
effect: string | null;
|
||||
pose: string;
|
||||
magnitude: number;
|
||||
gaze: GazeDirection;
|
||||
}
|
||||
|
||||
const EFFECT_GLYPH: Record<string, string> = {
|
||||
臉紅: "◕‸◕",
|
||||
汗: "💧",
|
||||
淚: "😢",
|
||||
青筋: "💢",
|
||||
音符: "♪",
|
||||
};
|
||||
|
||||
const WIDTH = 200;
|
||||
const HEIGHT = 260;
|
||||
const BODY_X = 100;
|
||||
const BODY_Y = 40;
|
||||
|
||||
// Q-5:以 react-native-skia 重現 apps/web/components/TachieStage.tsx(PixiJS)的分層合成與微動態,
|
||||
// 沿用同一套佔位色塊幾何與 magnitude 縮放公式,維持三端視覺一致。差分選用規則仍在後端
|
||||
// (/tachie/:characterId/:userId/expression),這裡只負責依 expression 的欄位畫出對應的差分。
|
||||
// 沒有安裝 react-native-reanimated(Skia 此版本已列為選用 peer dependency),改用一般 React state
|
||||
// 搭配 requestAnimationFrame 驅動眨眼/呼吸這種低頻率的常駐動態,避免多引入一個原生依賴與 babel 設定。
|
||||
export function TachieStage({ expression }: { expression: TachieExpression | null }) {
|
||||
const [breathPhase, setBreathPhase] = useState(0);
|
||||
const [blinking, setBlinking] = useState(false);
|
||||
const blinkClockRef = useRef(0);
|
||||
const lastTickRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let raf: number;
|
||||
function tick(now: number) {
|
||||
const last = lastTickRef.current ?? now;
|
||||
const deltaTime = ((now - last) / 1000) * 60; // 對齊 web 版以「每 frame(60fps 基準)」為單位的節奏
|
||||
lastTickRef.current = now;
|
||||
|
||||
setBreathPhase((phase) => phase + deltaTime * 0.03);
|
||||
blinkClockRef.current += deltaTime;
|
||||
const cycle = blinkClockRef.current % 180; // 約每 3 秒眨一次眼,與 web 版一致
|
||||
setBlinking(cycle > 172);
|
||||
|
||||
raf = requestAnimationFrame(tick);
|
||||
}
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, []);
|
||||
|
||||
// O-4 外顯度縮放:magnitude 越小,差分動作幅度越小;佔位資產仍保留最低幅度,確保肉眼看得出「有變化」。
|
||||
const magnitude = Math.max(0.12, expression?.magnitude ?? 0.12);
|
||||
const bodyScaleY = 1 + Math.sin(breathPhase) * 0.015;
|
||||
const eyeScaleY = blinking ? 0.1 : 1;
|
||||
|
||||
const eyebrowY = -26 - 6 * magnitude;
|
||||
const gazeDx = expression?.gaze === "AWAY" ? -8 : 0;
|
||||
const gazeDy = expression?.gaze === "UP" ? -5 : 0;
|
||||
const eyeHeight = 6 + 6 * magnitude;
|
||||
const isFrown = expression?.emotionTag === "SAD" || expression?.emotionTag === "GRUMPY";
|
||||
const curve = 10 * magnitude * (isFrown ? 1 : -1);
|
||||
const mouthPath = `M -12 6 Q 0 ${6 + curve} 12 6`;
|
||||
|
||||
const glyph = expression?.effect ? (EFFECT_GLYPH[expression.effect] ?? expression.effect) : null;
|
||||
|
||||
return (
|
||||
<View style={{ width: WIDTH, height: HEIGHT }}>
|
||||
<Canvas style={StyleSheet.absoluteFill}>
|
||||
{/* O-1/O-4 分層合成(由下而上):身體姿勢 → 服裝層 → 表情層(眉/眼/口)→ 動態層(眨眼/呼吸,常駐)。 */}
|
||||
<Group transform={[{ translateX: BODY_X }, { translateY: BODY_Y }, { scaleY: bodyScaleY }]}>
|
||||
<RoundedRect x={-55} y={0} width={110} height={180} r={24} color="#FFB3C6" />
|
||||
<Rect x={-55} y={95} width={110} height={85} color="#9B8CFF" />
|
||||
|
||||
<Group transform={[{ translateY: -8 }]}>
|
||||
<Rect x={-40} y={eyebrowY} width={40} height={4} color="#4A3B3B" />
|
||||
<Rect x={0} y={eyebrowY} width={40} height={4} color="#4A3B3B" />
|
||||
|
||||
<Group transform={[{ translateX: -20 + gazeDx }, { translateY: -10 + gazeDy }, { scaleY: eyeScaleY }]}>
|
||||
<Oval x={-6} y={-eyeHeight} width={12} height={eyeHeight * 2} color="#2F2F2F" />
|
||||
</Group>
|
||||
<Group transform={[{ translateX: 20 + gazeDx }, { translateY: -10 + gazeDy }, { scaleY: eyeScaleY }]}>
|
||||
<Oval x={-6} y={-eyeHeight} width={12} height={eyeHeight * 2} color="#2F2F2F" />
|
||||
</Group>
|
||||
|
||||
<Path path={mouthPath} style="stroke" strokeWidth={3} strokeCap="round" strokeJoin="round" color="#8A4A4A" />
|
||||
</Group>
|
||||
</Group>
|
||||
</Canvas>
|
||||
{glyph && <Text style={styles.effectGlyph}>{glyph}</Text>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
effectGlyph: { position: "absolute", left: 140, top: 30, fontSize: 20 },
|
||||
});
|
||||
|
||||
// TachieStage.web.tsx 透過 WithSkiaWeb 動態載入這個模組時要求 default export;
|
||||
// 原生端(TachieStage.tsx)用回具名匯出,兩邊都指向同一份實作,不重複維護兩套繪製邏輯。
|
||||
export default TachieStage;
|
||||
@@ -0,0 +1,3 @@
|
||||
// 原生端(iOS/Android/Expo Go)直接用 react-native-skia 的原生繪圖引擎,不需要 Web 專用的
|
||||
// CanvasKit WASM 載入流程——見 TachieStage.web.tsx 才是那條路徑要走的地方。
|
||||
export { TachieStage, type TachieExpression, type GazeDirection } from "./TachieStage.impl";
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Text } from "react-native";
|
||||
// eslint-disable-next-line import/no-unresolved -- 深層路徑是 react-native-skia 官方文件指定的 Web 專用匯出位置。
|
||||
import { WithSkiaWeb } from "@shopify/react-native-skia/lib/module/web";
|
||||
import type { GazeDirection, TachieExpression } from "./TachieStage.impl";
|
||||
|
||||
export type { GazeDirection, TachieExpression };
|
||||
|
||||
// Expo web 走瀏覽器 CanvasKit(WASM),需要先非同步載入才能畫圖——原生端沒有這個限制,
|
||||
// 所以原生/網頁各自一份極薄的進入點,實際繪製邏輯全部共用 TachieStage.impl.tsx。
|
||||
// canvaskit.wasm 已由 `npx setup-skia-web` 放在 apps/mobile/public/,走 Metro web 的靜態根目錄慣例。
|
||||
export function TachieStage(props: { expression: TachieExpression | null }) {
|
||||
return (
|
||||
<WithSkiaWeb
|
||||
getComponent={() => import("./TachieStage.impl")}
|
||||
componentProps={props}
|
||||
fallback={<Text>立繪載入中…</Text>}
|
||||
opts={{ locateFile: () => "/canvaskit.wasm" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user