feat: 食品添加剂课程业务开发(接待/主场景/取证/记录/信函学习页面)

This commit is contained in:
zhongyidian committed 2026-08-31 14:44:13 +08:00
1 parent ad37dfeb24
commit bf0c194d16
192 files changed
+4675 -624

No files matched your search

+166
View File
@@ -0,0 +1,166 @@
import { useEffect, useRef } from "react";
import { ImageButton } from "@/components/common";
import { getAppPortalRoot } from "@/components/common/canvasPortal";
import { Overlay } from "@/components/ui/overlay";
import { getStaticImage } from "@/utils/assets";
import type { EvidenceInfoDialogProps } from "./EvidenceInfoDialog.types";
import type { Evidence } from "./evidence.types";
const CANVAS_WIDTH = 1920;
const CANVAS_HEIGHT = 1080;
// 底板-物证信息 原图 724 x 829
const BOARD_WIDTH = 724;
const BOARD_HEIGHT = 829;
const BOARD_GAP = 180;
const BOARD_MARGIN = 56;
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function getDialogLayout(evidence: Evidence) {
const evidenceRight = evidence.left + evidence.width;
const rightPosition = evidenceRight + BOARD_GAP;
const leftPosition = evidence.left - BOARD_GAP - BOARD_WIDTH;
const placeOnRight =
rightPosition + BOARD_WIDTH <= CANVAS_WIDTH || leftPosition < 0;
const boardLeft = clamp(
placeOnRight ? rightPosition : leftPosition,
BOARD_MARGIN,
CANVAS_WIDTH - BOARD_WIDTH - BOARD_MARGIN,
);
const boardTop = clamp(
evidence.top + evidence.height / 2 - BOARD_HEIGHT / 2,
BOARD_MARGIN,
CANVAS_HEIGHT - BOARD_HEIGHT - BOARD_MARGIN,
);
const lineLeft = placeOnRight ? evidenceRight : boardLeft + BOARD_WIDTH;
const lineWidth = placeOnRight
? boardLeft - evidenceRight
: evidence.left - (boardLeft + BOARD_WIDTH);
return {
boardLeft,
boardTop,
lineLeft,
lineTop: evidence.top + evidence.height / 2,
lineWidth: Math.max(lineWidth, 0),
};
}
export function EvidenceInfoDialog({
open,
evidence,
onClose,
}: EvidenceInfoDialogProps) {
const closeRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
const animationFrame = requestAnimationFrame(() => {
closeRef.current?.focus();
});
return () => cancelAnimationFrame(animationFrame);
}, [open]);
if (!evidence) return null;
const layout = getDialogLayout(evidence);
return (
<Overlay open={open} onClose={onClose} container={getAppPortalRoot}>
<div className="pointer-events-none relative h-270 w-480">
<img
src={getStaticImage(evidence.image)}
alt=""
aria-hidden="true"
draggable={false}
className="absolute max-w-none select-none"
style={{
left: evidence.left,
top: evidence.top,
width: evidence.width,
height: evidence.height,
}}
/>
<span
aria-hidden="true"
className="absolute flex h-17 min-w-55 items-center justify-center rounded-full bg-[#fff0b0] px-8 text-[34px] font-medium text-[#7a431d]"
style={{
left: evidence.left + evidence.width - 188,
top:
evidence.top +
evidence.height -
(evidence.statusBottom ?? 12) -
68,
}}
>
已调查
</span>
{layout.lineWidth > 0 && (
<div
aria-hidden="true"
className="absolute -translate-y-1/2 border-[#fff0aa] border-t-8 border-dotted"
style={{
left: layout.lineLeft,
top: layout.lineTop,
width: layout.lineWidth,
}}
/>
)}
<div
role="dialog"
aria-modal="true"
aria-labelledby="evidence-info-title"
className="pointer-events-auto absolute"
style={{
left: layout.boardLeft,
top: layout.boardTop,
width: BOARD_WIDTH,
height: BOARD_HEIGHT,
}}
>
<img
src={getStaticImage("main-scene/panels/底板-物证信息")}
alt=""
aria-hidden="true"
draggable={false}
className="pointer-events-none absolute inset-0 h-full w-full select-none"
/>
<div className="relative h-full px-17 pt-42.5 text-[#55525a]">
<h2
id="evidence-info-title"
className="border-b-2 border-dashed border-[#c9a77c] pb-7 text-[44px] font-medium tracking-[3px]"
>
{evidence.name}
</h2>
<div className="mt-24 flex flex-col gap-5">
{evidence.description.map((paragraph) => (
<p key={paragraph} className="text-[28px] leading-[1.8]">
{paragraph}
</p>
))}
</div>
</div>
<ImageButton
ref={closeRef}
src="main-scene/buttons/按钮-关闭物证信息"
alt="关闭物证信息"
aria-label="关闭物证信息"
width={113}
height={114}
className="absolute -right-8 top-0"
onClick={onClose}
/>
</div>
</div>
</Overlay>
);
}
@@ -0,0 +1,7 @@
import type { Evidence } from "./evidence.types";
export interface EvidenceInfoDialogProps {
open: boolean;
evidence: Evidence | null;
onClose: () => void;
}
+70
View File
@@ -0,0 +1,70 @@
import type { Evidence } from "./evidence.types";
// 演示版物证清单:位置与尺寸为 1920 x 1080 设计稿坐标,描述为占位文案
export const EVIDENCES: Evidence[] = [
{
id: "cola",
name: "可乐",
image: "main-scene/evidence/证物箱-可乐",
description: ["碳酸饮料,含磷酸与咖啡因。", "可乐中的磷酸可能影响钙吸收。"],
left: 580,
top: 140,
width: 198,
height: 499,
},
{
id: "jelly",
name: "果冻",
image: "main-scene/evidence/证物箱-果冻",
description: [
"胶凝类食品,主要成分为增稠剂。",
"果冻常使用卡拉胶、魔芋胶等。",
],
left: 80,
top: 510,
width: 709,
height: 507,
statusBottom: 130,
},
{
id: "orange-juice",
name: "橙汁",
image: "main-scene/evidence/证物箱-橙汁",
description: ["果汁饮料,含糖量与添加剂需关注。"],
left: 840,
top: 80,
width: 193,
height: 485,
},
{
id: "grape-soda",
name: "葡萄味气泡水",
image: "main-scene/evidence/证物箱-葡萄味气泡水",
description: ["气泡饮料,含二氧化碳与食用香精。"],
left: 1140,
top: 140,
width: 190,
height: 469,
},
{
id: "candy",
name: "软糖",
image: "main-scene/evidence/证物箱-软糖",
description: ["凝胶糖果,含着色剂与甜味剂。"],
left: 760,
top: 560,
width: 492,
height: 345,
},
//吐司
{
id: "toast",
name: "吐司",
image: "main-scene/evidence/证物箱-吐司",
description: ["面包,含碳水化合物与蛋白质。"],
left: 1350,
top: 400,
width: 458,
height: 560,
},
];
+16
View File
@@ -0,0 +1,16 @@
/** 物证信息(演示版先写死在前端) */
export interface Evidence {
id: string;
name: string;
/** 证物图片资源 key,相对 src/assets/images/ */
image: string;
/** 物证说明,多段 */
description: string[];
/** 在证物子场景中的绝对定位与尺寸(1920 x 1080 设计稿坐标) */
left: number;
top: number;
width: number;
height: number;
/** 状态标签距证物定位框底部的距离 */
statusBottom?: number;
}
+142
View File
@@ -0,0 +1,142 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
BackgroundContainer,
ImageButton,
VideoOverlay,
} from "@/components/common";
import { useEvidenceStore } from "@/stores/modules/evidence";
import { cn } from "@/utils";
import { getPublicPath } from "@/utils/assets";
import { EvidenceInfoDialog } from "./EvidenceInfoDialog";
import { EVIDENCES } from "./evidence";
// 演示版:学习视频暂用 demo 占位
const STUDY_VIDEO = "video/demo.mp4";
export default function Evidence() {
const navigate = useNavigate();
const selectedEvidenceId = useEvidenceStore(
(state) => state.selectedEvidenceId,
);
const investigatedEvidenceIds = useEvidenceStore(
(state) => state.investigatedEvidenceIds,
);
const studyVideoWatched = useEvidenceStore(
(state) => state.studyVideoWatched,
);
const closeEvidence = useEvidenceStore((state) => state.closeEvidence);
const openEvidence = useEvidenceStore((state) => state.openEvidence);
const markStudyVideoWatched = useEvidenceStore(
(state) => state.markStudyVideoWatched,
);
const [videoOpen, setVideoOpen] = useState(false);
const videoFinishedRef = useRef(false);
const selectedEvidence =
EVIDENCES.find((item) => item.id === selectedEvidenceId) ?? null;
// 全部物证调查完成、且最后一个详情窗关闭后,播放学习视频(只播一次)
useEffect(() => {
if (studyVideoWatched) return;
if (investigatedEvidenceIds.length < EVIDENCES.length) return;
if (selectedEvidenceId !== null) return;
setVideoOpen(true);
}, [investigatedEvidenceIds.length, selectedEvidenceId, studyVideoWatched]);
// 视频看完(或跳过)后进入调查流水记录
const handleVideoFinish = useCallback(() => {
if (videoFinishedRef.current) return;
videoFinishedRef.current = true;
markStudyVideoWatched();
setVideoOpen(false);
navigate("/record");
}, [markStudyVideoWatched, navigate]);
return (
<BackgroundContainer
src="main-scene/backgrounds/背景"
className="relative h-full w-full"
>
{/* 返回主场景 */}
<div className="absolute right-10 top-10">
<ImageButton
src="components/button/按钮-返回"
alt="返回主场景"
width={244}
height={98}
onClick={() => navigate("/main-scene")}
/>
</div>
{/* 证物清单 */}
{EVIDENCES.map((item) => {
const investigated = investigatedEvidenceIds.includes(item.id);
const status = investigated ? "已调查" : "待调查";
return (
<div
key={item.id}
className="absolute"
style={{
left: item.left,
top: item.top,
width: item.width,
height: item.height,
}}
>
<ImageButton
src={item.image}
alt={item.name}
aria-label={`查看证物:${item.name},${status}`}
width={item.width}
height={item.height}
onClick={() => openEvidence(item.id)}
/>
<span
aria-hidden="true"
className={cn(
"pointer-events-none absolute -right-8 flex h-17 min-w-55 items-center justify-center rounded-full px-8 text-[34px] font-medium",
investigated
? "bg-[#fff0b0] text-[#7a431d]"
: "bg-[#4a4845]/85 text-white",
)}
style={{ bottom: item.statusBottom ?? 12 }}
>
{status}
</span>
</div>
);
})}
{/* 提示框 */}
<div className="absolute bottom-0 left-1/2 -translate-x-1/2">
<BackgroundContainer
src="main-scene/interactions/提示"
className="w-393 h-49 px-34 pt-15 pb-4"
>
<div className="ml-65 flex h-full items-center text-5xl text-[#9B5824]">
请点击桌上的物证,逐个查看({investigatedEvidenceIds.length}/
{EVIDENCES.length})
</div>
</BackgroundContainer>
</div>
<EvidenceInfoDialog
open={selectedEvidence !== null}
evidence={selectedEvidence}
onClose={closeEvidence}
/>
<VideoOverlay
open={videoOpen}
src={getPublicPath(STUDY_VIDEO)}
skipLabel="跳过"
onEnded={handleVideoFinish}
onSkip={handleVideoFinish}
/>
</BackgroundContainer>
);
}
+25 -52
View File
@@ -1,60 +1,33 @@
import { useEffect } from "react";
import { Link } from "react-router-dom";
import { GuideSpotlight } from "@/components/common";
import { Button } from "@/components/ui/button";
import { useAppStore, useSpotlightStore } from "@/stores";
import { useNavigate } from "react-router-dom";
import {
BackgroundContainer,
GuideSpotlight,
ImageButton,
} from "@/components/common";
export default function Home() {
const { count, increment, reset } = useAppStore();
useEffect(() => {
useSpotlightStore.getState().startSpotlight({
courseId: "template-home",
spotlightId: "intro",
steps: [
{
id: "counter",
target: { dataGuide: "counter-actions" },
title: "状态管理示例",
description: "这里演示 Zustand 的基础用法。",
placement: "bottom",
},
{
id: "about-link",
target: { dataGuide: "about-link" },
title: "路由跳转",
description: "点击这里可以跳转到关于页面。",
placement: "top",
},
],
});
}, []);
const navigate = useNavigate();
return (
<>
<div className="space-y-6 text-center">
<div className="space-y-2">
<h1 className="text-4xl font-bold">首页</h1>
<p className="text-lg text-gray-600">欢迎来到首页</p>
</div>
<div
className="flex items-center justify-center gap-4"
data-guide="counter-actions"
>
<Button onClick={increment}>计数:{count}</Button>
<Button variant="secondary" onClick={reset}>
重置
</Button>
</div>
<div className="flex justify-center gap-4">
<Link to="/about" data-guide="about-link">
<Button>关于我们</Button>
</Link>
</div>
<BackgroundContainer src="home/底图-首页" className="h-full w-full">
<div className="absolute bottom-20 left-1/2 -translate-x-1/2 flex items-center justify-center gap-6">
<ImageButton
src="home/按钮-进入课程"
alt="进入课程"
width={317}
height={102}
onClick={() => navigate("/reception")}
/>
<ImageButton
src="home/按钮-课程简介"
alt="课程简介"
width={317}
height={102}
onClick={() => console.log("image button clicked")}
/>
</div>
<GuideSpotlight />
</>
</BackgroundContainer>
);
}
@@ -0,0 +1,563 @@
.letter {
filter: drop-shadow(0 18px 22px rgb(70 37 12 / 28%));
}
.highlight {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border: 2px solid rgb(255 255 255 / 82%);
border-radius: 999px;
color: #fffdf2;
font-weight: 700;
font-size: 17px;
line-height: 1;
letter-spacing: 0.2px;
white-space: nowrap;
text-shadow: 0 1px 2px rgb(93 48 20 / 36%);
box-shadow:
0 2px 5px rgb(77 44 17 / 18%),
inset 0 1px 2px rgb(255 255 255 / 34%);
cursor: grab;
}
.highlight:active {
cursor: grabbing;
}
.highlightPlaced {
opacity: 0;
}
.highlightOverlay {
min-width: 250px;
height: 48px;
padding: 0 22px;
font-size: 22px;
box-shadow:
0 12px 26px rgb(75 41 13 / 28%),
inset 0 1px 2px rgb(255 255 255 / 40%);
}
.board {
position: absolute;
left: 835px;
top: 140px;
width: 1035px;
height: 825px;
box-sizing: border-box;
border: 20px solid #9a5729;
border-radius: 34px 34px 48px 48px;
background:
radial-gradient(circle at 12% 18%, rgb(255 255 255 / 24%) 0 3px, transparent 4px),
linear-gradient(100deg, #ffe8b5 0%, #fff2c9 48%, #f8d994 100%);
box-shadow:
0 22px 34px rgb(62 31 8 / 40%),
inset 0 0 0 5px #e7a45c,
inset 0 0 0 10px #70401f;
}
.board::before {
content: "";
position: absolute;
z-index: 4;
left: -34px;
top: -54px;
width: calc(100% + 68px);
height: 58px;
box-sizing: border-box;
border: 7px solid #774019;
border-radius: 30px;
background:
linear-gradient(90deg, transparent 0 10px, #f3aa54 11px 18px, transparent 19px calc(100% - 19px), #f3aa54 calc(100% - 18px) calc(100% - 11px), transparent calc(100% - 10px)),
linear-gradient(#d88639, #f0b36a 40%, #b76528 44%, #d88a3f 72%, #8d471e);
box-shadow:
inset 0 2px 3px rgb(255 239 181 / 75%),
0 6px 8px rgb(65 30 8 / 25%);
}
.board::after {
content: "";
position: absolute;
inset: 7px;
pointer-events: none;
border: 3px solid rgb(169 96 38 / 55%);
border-radius: 17px 17px 29px 29px;
box-shadow: inset 0 0 28px rgb(165 93 32 / 24%);
}
.hanger {
position: absolute;
z-index: 5;
top: -104px;
width: 25px;
height: 104px;
border: 4px solid #5a321b;
border-top: 0;
border-radius: 0 0 10px 10px;
background: linear-gradient(90deg, #6e3d20, #b77442 44%, #5e321b);
box-shadow: 4px 2px 4px rgb(54 26 9 / 24%);
}
.hangerLeft {
left: 285px;
}
.hangerRight {
right: 270px;
}
.boardTitle {
position: absolute;
z-index: 7;
left: 50%;
top: -20px;
width: 535px;
height: 92px;
transform: translateX(-50%);
box-sizing: border-box;
border: 6px solid #7b401b;
border-radius: 26px;
background: linear-gradient(#c9803d, #9b5424 58%, #7f411d);
color: #ffebbd;
font-weight: 800;
font-size: 43px;
line-height: 78px;
letter-spacing: 14px;
text-align: center;
text-indent: 14px;
text-shadow:
0 3px 0 #6c3215,
0 0 5px #fff2bb;
box-shadow:
inset 0 3px 4px rgb(255 222 141 / 55%),
0 8px 10px rgb(82 39 11 / 28%);
}
.boardTitle::before,
.boardTitle::after {
content: "•";
position: absolute;
top: 0;
color: #e9ad5f;
font-size: 30px;
text-shadow: 0 2px #6c3215;
}
.boardTitle::before {
left: 18px;
}
.boardTitle::after {
right: 18px;
}
.slotGrid {
position: absolute;
z-index: 2;
inset: 108px 55px 55px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: repeat(2, minmax(0, 1fr));
gap: 42px 45px;
}
.slot {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
min-height: 0;
box-sizing: border-box;
border: 4px dashed #d7a65a;
border-radius: 28px;
background: rgb(255 228 168 / 28%);
transition: 160ms ease;
}
.slot::before,
.slot::after {
content: "✦";
position: absolute;
color: #dca04b;
font-size: 28px;
}
.slot::before {
left: 15px;
top: 14px;
}
.slot::after {
right: 15px;
bottom: 12px;
}
.slotOver {
border-color: #b66d28;
background: rgb(248 194 88 / 34%);
box-shadow: 0 0 0 8px rgb(220 147 50 / 13%);
transform: scale(1.015);
}
.magnifier {
width: 94px;
height: 94px;
color: #dda858;
opacity: 0.34;
}
.card {
position: relative;
width: 100%;
height: 100%;
box-sizing: border-box;
padding: 15px 22px 17px;
border: 4px solid color-mix(in srgb, var(--clue-color), #9b591c 25%);
border-radius: 9px;
background:
repeating-linear-gradient(0deg, rgb(151 93 32 / 4%) 0 1px, transparent 1px 7px),
#fff9e8;
box-shadow:
0 9px 12px rgb(97 52 13 / 21%),
inset 0 0 0 3px #fff;
transform: rotate(var(--card-rotate));
animation: cardIn 420ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.card::after {
content: "";
position: absolute;
inset: 10px;
pointer-events: none;
border: 2px dashed color-mix(in srgb, var(--clue-color), transparent 35%);
border-radius: 5px;
}
@keyframes cardIn {
from {
opacity: 0;
transform: translateY(-25px) rotate(calc(var(--card-rotate) - 2deg)) scale(0.86);
}
}
.bookmark {
position: absolute;
z-index: 3;
left: 17px;
top: -9px;
width: 44px;
height: 58px;
box-sizing: border-box;
padding-top: 8px;
clip-path: polygon(0 0, 100% 0, 100% 100%, 50% 76%, 0 100%);
background: var(--clue-color);
color: white;
font-size: 24px;
text-align: center;
text-shadow: 0 2px 1px rgb(97 45 15 / 35%);
box-shadow: inset 0 0 0 3px rgb(255 255 255 / 55%);
}
.pin {
position: absolute;
z-index: 4;
right: 15px;
top: -13px;
width: 21px;
height: 21px;
border: 3px solid #9b571d;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #fff0ad 0 15%, #e99c35 25% 62%, #a95d1d 64%);
box-shadow: 2px 3px 3px rgb(71 36 13 / 28%);
}
.pin::after {
content: "";
position: absolute;
left: 13px;
top: 14px;
width: 3px;
height: 24px;
transform: rotate(34deg);
transform-origin: top;
background: #875123;
}
.cardIcon {
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 128px;
height: 128px;
margin: 0 auto 2px;
border: 6px solid var(--clue-color);
border-radius: 50%;
background: #fff1c9;
font-size: 74px;
line-height: 1;
box-shadow:
0 0 0 5px #fff9e8,
0 0 0 8px var(--clue-color);
}
.cardTitle {
position: relative;
z-index: 3;
width: 92%;
height: 60px;
margin: 4px auto 8px;
border: 3px solid #fff7d4;
border-radius: 8px;
background: var(--clue-dark-color);
color: #fff8db;
font-weight: 800;
font-size: 34px;
line-height: 54px;
letter-spacing: 4px;
text-align: center;
text-shadow: 0 2px 2px rgb(88 39 12 / 38%);
box-shadow: 0 0 0 3px var(--clue-dark-color);
}
.cardLabel {
position: relative;
z-index: 2;
overflow: hidden;
width: 92%;
margin: 0 auto;
color: #694424;
font-size: 21px;
line-height: 37px;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.completedStamp {
position: absolute;
z-index: 12;
right: 50px;
bottom: 65px;
transform: rotate(-8deg);
border: 6px double #ff7779;
border-radius: 20px;
padding: 7px 17px;
color: #ff7478;
font-weight: 800;
font-size: 34px;
letter-spacing: 2px;
background: rgb(255 250 235 / 76%);
box-shadow: inset 0 0 0 2px #ff9a9c;
animation: stampIn 420ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes stampIn {
from {
opacity: 0;
transform: rotate(-8deg) scale(1.8);
}
}
.investigateButton {
position: absolute;
z-index: 12;
left: 50%;
transform: translateX(-50%);
min-width: 260px;
height: 75px;
border: 6px double #ffe1a1;
border-radius: 27px;
background: linear-gradient(#eead56, #d77c24 58%, #ba601b);
color: #fff9d9;
font-weight: 800;
font-size: 36px;
letter-spacing: 5px;
text-shadow: 0 3px 2px #9a4819;
box-shadow:
0 4px 0 #824019,
0 9px 16px rgb(73 34 9 / 30%);
cursor: pointer;
animation: cardIn 420ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.investigateButton:hover {
filter: brightness(1.06);
}
.investigateButton:active {
transform: translateX(-50%) translateY(3px);
box-shadow:
0 1px 0 #824019,
0 5px 10px rgb(73 34 9 / 24%);
}
.bottomHint {
position: absolute;
z-index: 20;
left: 50%;
bottom: 23px;
display: flex;
align-items: center;
gap: 20px;
transform: translateX(-50%);
color: #fff4d2;
font-weight: 700;
font-size: 30px;
white-space: nowrap;
text-shadow: 0 2px 4px rgb(88 42 13 / 62%);
}
.bottomHint::before,
.bottomHint::after {
content: "";
width: 195px;
height: 2px;
background: linear-gradient(90deg, transparent, #fff4d2 35%, #fff4d2 65%, transparent);
}
.bulb {
font-size: 45px;
filter: drop-shadow(0 2px 2px rgb(89 44 15 / 42%));
}
@media (prefers-reduced-motion: reduce) {
.card,
.completedStamp,
.investigateButton {
animation: none;
}
}
/* 制作稿已提供完整交互素材,以下样式只负责定位和状态变化。 */
.sourceHighlight {
position: absolute;
z-index: 2;
pointer-events: none;
transition: opacity 180ms ease;
}
.highlightPlaced {
opacity: 0;
}
.highlightOverlay {
position: relative !important;
left: auto !important;
top: auto !important;
min-width: 0;
padding: 0;
filter: drop-shadow(0 12px 16px rgb(70 36 10 / 30%));
}
.hotspot {
z-index: 3;
cursor: grab;
}
.hotspot:active {
cursor: grabbing;
}
.slot {
border: 0;
border-radius: 0;
background: transparent;
}
.slot::before,
.slot::after {
display: none;
}
.slotOver {
border: 0;
background: transparent;
filter: brightness(1.06) drop-shadow(0 0 10px rgb(205 132 33 / 42%));
box-shadow: none;
transform: scale(1.02);
}
.card {
position: relative;
width: auto;
height: 100%;
max-width: 100%;
box-sizing: border-box;
padding: 0;
border: 0;
border-radius: 0;
background: none;
object-fit: contain;
box-shadow: none;
transform: none;
animation: cardIn 420ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.completedStamp {
right: 42px;
bottom: 58px;
width: 248px;
height: 124px;
box-sizing: border-box;
padding: 0;
border: 0;
border-radius: 0;
background: none;
box-shadow: none;
object-fit: contain;
}
.investigateButton {
left: 1352px;
top: 900px;
bottom: auto;
width: 241px;
min-width: 0;
height: 81px;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.investigateButton img {
display: block;
width: 100%;
height: 100%;
}
.investigateButton:active {
transform: translateX(-50%) translateY(3px);
box-shadow: none;
}
.bottomHint {
bottom: 0;
gap: 18px;
color: inherit;
font-size: inherit;
text-shadow: none;
}
.bottomHint::before,
.bottomHint::after {
display: none;
}
.bottomHint > span {
width: 220px;
height: 3px;
background: linear-gradient(90deg, transparent, #fff5d3 20% 80%, transparent);
}
.bottomHint > img {
width: 849px;
height: 108px;
object-fit: contain;
}
+303
View File
@@ -0,0 +1,303 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { appConfig } from "@/app.config";
import { BackgroundContainer, VideoOverlay } from "@/components/common";
import {
DragDropProvider,
type DragDropProviderProps,
Draggable,
Droppable,
} from "@/components/course-interactions/drag-drop";
import { useCourseCanvas } from "@/course-runtime/canvas";
import { useCaseStore } from "@/stores/modules/case";
import { cn, getPublicPath, getStaticImage } from "@/utils";
import styles from "./LetterStudy.module.css";
import { LETTER_CLUES } from "./letterStudy.data";
import type { ClueCardProps, ClueHighlightProps } from "./letterStudy.types";
const LETTER_WIDTH = 697;
const LETTER_HEIGHT = 834;
const SLOT_COUNT = LETTER_CLUES.length;
const COMPLETION_VIDEO = "video/demo.mp4";
const SLOTS_STORAGE_KEY = `${appConfig.storage.prefix}-letter-study-slots-session`;
const createEmptySlots = () =>
Array.from({ length: SLOT_COUNT }, () => null as string | null);
const restoreSlots = (): (string | null)[] => {
try {
const stored = sessionStorage.getItem(SLOTS_STORAGE_KEY);
if (!stored) return createEmptySlots();
const parsed: unknown = JSON.parse(stored);
if (!Array.isArray(parsed) || parsed.length !== SLOT_COUNT) {
return createEmptySlots();
}
const clueIds = new Set(LETTER_CLUES.map((clue) => clue.id));
const valid = parsed.every(
(id): id is string | null =>
id === null || (typeof id === "string" && clueIds.has(id)),
);
if (!valid) return createEmptySlots();
const placedIds = parsed.filter(
(id): id is string => typeof id === "string",
);
return new Set(placedIds).size === placedIds.length
? parsed
: createEmptySlots();
} catch {
return createEmptySlots();
}
};
const findClue = (id: string) => LETTER_CLUES.find((clue) => clue.id === id);
function ClueHighlight({ clue, className }: ClueHighlightProps) {
return (
<img
src={getStaticImage(clue.dragImage)}
alt=""
aria-hidden="true"
draggable={false}
className={cn("select-none", className)}
style={{
left: clue.dragArea.left * LETTER_WIDTH,
top: clue.dragArea.top * LETTER_HEIGHT,
width: clue.dragArea.width * LETTER_WIDTH,
height: clue.dragArea.height * LETTER_HEIGHT,
maxWidth: "none",
}}
/>
);
}
function ClueCard({ clue, className }: ClueCardProps) {
return (
<img
src={getStaticImage(clue.cardImage)}
alt={`${clue.label}调查卡`}
draggable={false}
className={cn(styles.card, "select-none", className)}
/>
);
}
export default function LetterStudy() {
const navigate = useNavigate();
const { scale } = useCourseCanvas();
const promptCaseRegistration = useCaseStore(
(state) => state.promptCaseRegistration,
);
const [activeClueId, setActiveClueId] = useState<string | null>(null);
const [slots, setSlots] = useState<(string | null)[]>(restoreSlots);
const [videoOpen, setVideoOpen] = useState(false);
const completionFinishedRef = useRef(false);
const placedIds = slots.filter((id): id is string => id !== null);
const allPlaced = placedIds.length === SLOT_COUNT;
useEffect(() => {
try {
sessionStorage.setItem(SLOTS_STORAGE_KEY, JSON.stringify(slots));
} catch {
// sessionStorage 不可用时仅退回当前页面内状态
}
}, [slots]);
const handleVideoFinish = useCallback(() => {
if (completionFinishedRef.current) return;
completionFinishedRef.current = true;
promptCaseRegistration();
setVideoOpen(false);
navigate("/main-scene");
}, [navigate, promptCaseRegistration]);
const handleDragStart: DragDropProviderProps["onDragStart"] = (event) => {
setActiveClueId(String(event.active.data.current?.clueId ?? ""));
};
const handleDragEnd: DragDropProviderProps["onDragEnd"] = (event) => {
setActiveClueId(null);
const clueId = String(event.active.data.current?.clueId ?? "");
const slotIndex = Number(String(event.over?.id ?? "").replace("slot-", ""));
if (!clueId || !event.over || Number.isNaN(slotIndex)) return;
setSlots((previousSlots) => {
if (previousSlots.includes(clueId) || previousSlots[slotIndex] !== null) {
return previousSlots;
}
const nextSlots = [...previousSlots];
nextSlots[slotIndex] = clueId;
return nextSlots;
});
};
return (
<BackgroundContainer
src="record/对话全屏背景"
className="relative h-full w-full overflow-hidden"
contentClassName="absolute inset-0 overflow-hidden"
>
<img
src={getStaticImage("title/推理进行中")}
alt="推理进行中"
draggable={false}
className="absolute left-5 top-0 z-30 h-auto w-131 select-none"
/>
<DragDropProvider
scale={scale}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={() => setActiveClueId(null)}
renderOverlay={({ activeId }) => {
const clue = findClue(String(activeId).split(":")[0]);
return clue ? (
<ClueHighlight
clue={clue}
className={cn("pointer-events-none", styles.highlightOverlay)}
/>
) : null;
}}
>
<div
className={cn("absolute left-12.5 top-38.5", styles.letter)}
style={{ width: LETTER_WIDTH, height: LETTER_HEIGHT }}
>
<img
src={getStaticImage("main-scene/letters/求助信-交互底板用")}
alt="恐慌市民乙先生的求助信"
draggable={false}
className="h-full w-full select-none"
/>
{LETTER_CLUES.map((clue) => {
const hidden =
placedIds.includes(clue.id) || activeClueId === clue.id;
return (
<ClueHighlight
key={`${clue.id}-image`}
clue={clue}
className={cn(
styles.sourceHighlight,
hidden && styles.highlightPlaced,
)}
/>
);
})}
{LETTER_CLUES.flatMap((clue) => {
const placed = placedIds.includes(clue.id);
return clue.hotspots.map((hotspot, index) => (
<Draggable
key={`${clue.id}:${index}`}
id={`${clue.id}:${index}`}
data={{ clueId: clue.id }}
disabled={placed}
aria-label={`拖动线索:${clue.label}`}
className={styles.hotspot}
style={{
position: "absolute",
left: `${hotspot.left * 100}%`,
top: `${hotspot.top * 100}%`,
width: `${hotspot.width * 100}%`,
height: `${hotspot.height * 100}%`,
}}
>
<span className="block h-full w-full" />
</Draggable>
));
})}
{allPlaced && (
<img
src={getStaticImage("record/敲章-线索已整理")}
alt="线索已整理"
draggable={false}
className={styles.completedStamp}
/>
)}
</div>
<section aria-label="案件线索板" className={styles.board}>
<span className={cn(styles.hanger, styles.hangerLeft)} />
<span className={cn(styles.hanger, styles.hangerRight)} />
<h2 className={styles.boardTitle}>案件线索板</h2>
<div className={styles.slotGrid}>
{slots.map((clueId, index) => {
const clue = clueId ? findClue(clueId) : undefined;
const slotId = `slot-${index}`;
return (
<Droppable
key={slotId}
id={slotId}
canDrop={() => clue === undefined}
className={({ isOver, canDrop }) =>
cn(styles.slot, isOver && canDrop && styles.slotOver)
}
>
{clue ? (
<ClueCard clue={clue} />
) : (
<img
src={getStaticImage("main-scene/interactions/拖拽区域")}
alt=""
aria-hidden="true"
draggable={false}
className="h-full w-full select-none object-contain"
/>
)}
</Droppable>
);
})}
</div>
</section>
{allPlaced && (
<button
type="button"
className={styles.investigateButton}
onClick={() => setVideoOpen(true)}
>
<img
src={getStaticImage("main-scene/buttons/按钮-深入调查")}
alt="深入调查"
draggable={false}
/>
</button>
)}
</DragDropProvider>
<div className={styles.bottomHint}>
<span />
<img
src={getStaticImage(
allPlaced
? "main-scene/interactions/恭喜关键线索提炼"
: "main-scene/interactions/拖拽提示",
)}
alt={
allPlaced
? "恭喜!关键线索已提炼完成,快去深入调查吧!"
: "拖拽信中关键信息至线索板上,生成调查方向"
}
draggable={false}
/>
<span />
</div>
<VideoOverlay
open={videoOpen}
src={getPublicPath(COMPLETION_VIDEO)}
skipLabel="跳过"
onEnded={handleVideoFinish}
onSkip={handleVideoFinish}
/>
</BackgroundContainer>
);
}
+66
View File
@@ -0,0 +1,66 @@
import type { LetterClue } from "./letterStudy.types";
/**
* 四张拖拽图片按求助信原图 697 x 834 定位。橙色和蓝色素材已经包含
* 跨行的两段高亮,因此每条说法只需要一个拖拽热区。
*/
export const LETTER_CLUES: LetterClue[] = [
{
id: "colorant",
label: "颜色鲜艳是因为色素多",
dragImage: "main-scene/interactions/拖拽-红",
cardImage: "main-scene/cards/卡片-色彩迷局",
dragArea: {
left: 0.2568,
top: 0.4551,
width: 0.3401,
height: 0.0647,
},
hotspots: [{ left: 0.2568, top: 0.4551, width: 0.3401, height: 0.0647 }],
},
{
id: "flavoring",
label: "水果味都是香精调出来的",
dragImage: "main-scene/interactions/拖拽-橙",
cardImage: "main-scene/cards/卡片-味觉密码",
dragArea: {
left: 0.0947,
top: 0.4551,
width: 0.7848,
height: 0.1187,
},
hotspots: [
{ left: 0.6069, top: 0.4551, width: 0.2726, height: 0.0647 },
{ left: 0.0947, top: 0.5102, width: 0.1722, height: 0.0647 },
],
},
{
id: "thickener",
label: "果冻Q弹是因为加了很多“胶”",
dragImage: "main-scene/interactions/拖拽-绿",
cardImage: "main-scene/cards/卡片-Q弹真相",
dragArea: {
left: 0.2568,
top: 0.5102,
width: 0.4648,
height: 0.0647,
},
hotspots: [{ left: 0.2568, top: 0.5102, width: 0.4648, height: 0.0647 }],
},
{
id: "preservative",
label: "面包放得久是因为防腐剂多",
dragImage: "main-scene/interactions/拖拽-蓝",
cardImage: "main-scene/cards/卡片-保鲜疑云",
dragArea: {
left: 0.0947,
top: 0.5102,
width: 0.7848,
height: 0.1199,
},
hotspots: [
{ left: 0.7346, top: 0.5102, width: 0.1449, height: 0.0647 },
{ left: 0.0947, top: 0.5653, width: 0.3099, height: 0.0647 },
],
},
];
@@ -0,0 +1,27 @@
/** 信纸上可拖拽线索图的比例位置和尺寸 */
export interface LetterClueDragArea {
left: number;
top: number;
width: number;
height: number;
}
/** 求助信里的一条关键信息,拖到线索板后生成调查方向 */
export interface LetterClue {
id: string;
label: string;
dragImage: string;
cardImage: string;
dragArea: LetterClueDragArea;
hotspots: LetterClueDragArea[];
}
export interface ClueCardProps {
clue: LetterClue;
className?: string;
}
export interface ClueHighlightProps {
clue: LetterClue;
className?: string;
}
@@ -0,0 +1,83 @@
import { useLayoutEffect, useRef } from "react";
import { ImageButton } from "@/components/common";
import { getStaticImage } from "@/utils/assets";
import { EASE_IN_OUT, gsap, useReducedMotion } from "@/utils/motion";
import type { CaseEntryProps } from "./CaseEntry.types";
// 待登记时浮出提醒卡片;初始和已登记状态均显示静态案件入口。
// offsetY 为相对案件槽位底边的下移量,各状态可独立微调。
const APPEARANCE = {
none: {
src: "main-scene/entries/入口-案件",
alt: "暂无待登记案件",
width: 72,
height: 70,
offsetY: 8,
},
pending: {
src: "main-scene/reminders/提醒-案件登记",
alt: "登记新案件",
width: 123,
height: 144,
offsetY: 46,
},
registered: {
src: "main-scene/entries/入口-案件",
alt: "案件已登记",
width: 72,
height: 70,
offsetY: 8,
},
} as const;
export function CaseEntry({ status, onClick }: CaseEntryProps) {
const floatRef = useRef<HTMLDivElement>(null);
const reduced = useReducedMotion();
const { src, alt, width, height, offsetY } = APPEARANCE[status];
useLayoutEffect(() => {
const node = floatRef.current;
if (status !== "pending" || reduced || !node) return;
const tween = gsap.fromTo(
node,
{ y: 0 },
{ y: -8, duration: 0.9, ease: EASE_IN_OUT, repeat: -1, yoyo: true },
);
return () => {
tween.kill();
};
}, [status, reduced]);
return (
<div className="relative h-[70px] w-[72px]">
<div
className="absolute left-1/2 w-max -translate-x-1/2"
style={{ bottom: -offsetY }}
>
<div ref={floatRef}>
{status === "pending" ? (
<ImageButton
src={src}
alt={alt}
aria-haspopup="dialog"
width={width}
height={height}
onClick={onClick}
/>
) : (
<img
src={getStaticImage(src)}
alt={alt}
width={width}
height={height}
draggable={false}
className="max-w-none select-none"
/>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,6 @@
import type { CaseRegistrationStatus } from "@/stores/types/case.types";
export interface CaseEntryProps {
status: CaseRegistrationStatus;
onClick: () => void;
}
@@ -0,0 +1,75 @@
import { useEffect, useRef } from "react";
import { ImageButton } from "@/components/common";
import { getAppPortalRoot } from "@/components/common/canvasPortal";
import { Overlay } from "@/components/ui/overlay";
import { getStaticImage } from "@/utils/assets";
import type { CaseRegistrationDialogProps } from "./CaseRegistrationDialog.types";
// 案件登记底板按原图 1387 x 1008 直出
const BOARD_WIDTH = 1387;
const BOARD_HEIGHT = 1008;
export function CaseRegistrationDialog({
open,
onRegister,
onClose,
}: CaseRegistrationDialogProps) {
const registerRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
const animationFrame = requestAnimationFrame(() => {
registerRef.current?.focus();
});
return () => cancelAnimationFrame(animationFrame);
}, [open]);
return (
<Overlay
open={open}
onClose={onClose}
container={getAppPortalRoot}
closeOnBackgroundClick={false}
contentClassName="flex items-center justify-center"
>
<div
role="dialog"
aria-modal="true"
aria-label="案件登记"
className="relative"
style={{ width: BOARD_WIDTH, height: BOARD_HEIGHT }}
>
<img
src={getStaticImage("main-scene/panels/案件登记")}
alt="案件名称:色彩迷局;主证物:五颜六色的食品;待调查:颜色这么鲜艳,色素肯定没少加;调查状态:待立案"
draggable={false}
className="pointer-events-none absolute inset-0 h-full w-full select-none"
/>
<ImageButton
ref={registerRef}
src="main-scene/buttons/按钮-立即立案"
alt="立即立案"
aria-label="立即登记色彩迷局案件"
width={487}
height={127}
className="absolute"
style={{ left: 450, top: 740 }}
onClick={onRegister}
/>
<ImageButton
src="main-scene/buttons/按钮-关闭"
alt="关闭案件登记"
aria-label="关闭案件登记"
width={129}
height={131}
className="absolute right-0 top-0"
onClick={onClose}
/>
</div>
</Overlay>
);
}
@@ -0,0 +1,5 @@
export interface CaseRegistrationDialogProps {
open: boolean;
onRegister: () => void;
onClose: () => void;
}
@@ -0,0 +1,51 @@
import { useLayoutEffect, useRef } from "react";
import { ImageButton } from "@/components/common";
import { EASE_OUT, gsap, useReducedMotion } from "@/utils/motion";
import type { EvidenceBoxProps } from "./EvidenceBox.types";
// 证物箱出现:从画面右侧滑入到桌面,带轻微回弹
export function EvidenceBox({ visible, onClick }: EvidenceBoxProps) {
const boxRef = useRef<HTMLButtonElement>(null);
const reduced = useReducedMotion();
useLayoutEffect(() => {
const node = boxRef.current;
if (!node) return;
if (!visible) {
gsap.set(node, { x: 0, opacity: 0 });
return;
}
if (reduced) {
gsap.set(node, { x: 0, opacity: 1 });
return;
}
gsap.fromTo(
node,
{ x: 300, opacity: 0 },
{ x: 0, opacity: 1, duration: 0.6, ease: EASE_OUT },
);
// 落地回弹:位置不动,轻微缩放压实再弹回
gsap.fromTo(
node,
{ scale: 0.85 },
{ scale: 1, duration: 0.5, delay: 0.55, ease: "back.out(2)" },
);
}, [visible, reduced]);
return (
<ImageButton
ref={boxRef}
src="main-scene/evidence/证物箱"
alt="证物箱"
aria-label="打开证物箱"
className="absolute"
style={{ left: 800, bottom: 240 }}
width={331}
height={299}
onClick={onClick}
/>
);
}
@@ -0,0 +1,4 @@
export interface EvidenceBoxProps {
visible: boolean;
onClick: () => void;
}
@@ -0,0 +1,80 @@
import { useEffect, useRef } from "react";
import { ImageButton } from "@/components/common";
import { getAppPortalRoot } from "@/components/common/canvasPortal";
import { Overlay } from "@/components/ui/overlay";
import { getStaticImage } from "@/utils/assets";
import type { HelpLetterDialogProps } from "./HelpLetterDialog.types";
// 求助信按原图 793 x 976 直出,1080 画布内垂直居中
const LETTER_WIDTH = 793;
const LETTER_HEIGHT = 976;
export function HelpLetterDialog({
open,
received,
onClose,
onReceive,
}: HelpLetterDialogProps) {
const primaryRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
const animationFrame = requestAnimationFrame(() => {
primaryRef.current?.focus();
});
return () => cancelAnimationFrame(animationFrame);
}, [open]);
return (
<Overlay
open={open}
onClose={onClose}
container={getAppPortalRoot}
closeOnBackgroundClick={false}
contentClassName="flex items-center justify-center"
>
{/* 按钮压在信纸内部靠底边,故容器为定位上下文 */}
<div
role="dialog"
aria-modal="true"
aria-label="求助信"
className="relative"
>
<img
src={getStaticImage("main-scene/letters/求助信")}
alt="来自恐慌市民Z先生的求助信,希望零食侦探事务所帮忙调查食品添加剂"
width={LETTER_WIDTH}
height={LETTER_HEIGHT}
draggable={false}
className="select-none"
/>
{received ? (
// 回看模式:信件已接收,只提供关闭
<ImageButton
ref={primaryRef}
src="main-scene/buttons/按钮-关闭"
alt="关闭求助信"
aria-label="关闭求助信"
width={86}
height={87}
className="absolute -right-6 -top-6"
onClick={onClose}
/>
) : (
<ImageButton
ref={primaryRef}
src="main-scene/buttons/按钮-接收"
alt="接收这封求助信"
width={231}
height={79}
className="absolute bottom-2 left-1/2 -translate-x-1/2"
onClick={onReceive}
/>
)}
</div>
</Overlay>
);
}
@@ -0,0 +1,7 @@
export interface HelpLetterDialogProps {
open: boolean;
/** 已接收的信件只做回看,不再展示接收/稍后按钮 */
received: boolean;
onClose: () => void;
onReceive: () => void;
}
@@ -0,0 +1,88 @@
import { useLayoutEffect, useRef } from "react";
import { ImageButton } from "@/components/common";
import { getStaticImage } from "@/utils/assets";
import { EASE_IN_OUT, gsap, useReducedMotion } from "@/utils/motion";
import type { MailEntryProps } from "./MailEntry.types";
// 三态外观:无邮件为静态图标,待接收为浮动提醒卡片,已接收为可回看入口。
// offsetY 为相对信箱槽位底边的下移量(px),各状态独立微调,互不影响。
const APPEARANCE = {
none: {
src: "main-scene/entries/入口-未有邮件",
alt: "暂无邮件",
width: 75,
height: 75,
offsetY: 8,
},
pending: {
src: "main-scene/reminders/提醒-新邮件",
alt: "你有一封新邮件",
width: 132,
height: 149,
offsetY: 46,
},
received: {
src: "main-scene/entries/入口-邮件",
alt: "查看邮件(1/1)",
width: 78,
height: 75,
offsetY: 8,
},
} as const;
export function MailEntry({ status, onClick }: MailEntryProps) {
const floatRef = useRef<HTMLDivElement>(null);
const reduced = useReducedMotion();
const { src, alt, width, height, offsetY } = APPEARANCE[status];
// 待接收状态上下轻浮,提示有新邮件待处理。
// 浮动动画放在外层 div 上,与 ImageButton 自带的 hover 缩放(作用在 button 上)分离,
// 避免两个 tween 打同一节点互相 overwrite/kill。用 layoutEffect 精确建/杀。
useLayoutEffect(() => {
const node = floatRef.current;
if (status !== "pending" || reduced || !node) return;
const tween = gsap.fromTo(
node,
{ y: 0 },
{ y: -8, duration: 0.9, ease: EASE_IN_OUT, repeat: -1, yoyo: true },
);
return () => {
tween.kill();
};
}, [status, reduced]);
// 槽位与右侧「案件登记」入口对齐;各状态用 offsetY 独立下移,互不牵连。
// w-max 必须保留:绝对定位盒子在 left-1/2 下可用宽度只剩一半,
// 会连同 reset.css 的 img{max-width:100%} 把图片压成半尺寸。
return (
<div className="relative h-[75px] w-[75px]">
<div
className="absolute left-1/2 w-max -translate-x-1/2"
style={{ bottom: -offsetY }}
>
<div ref={floatRef}>
{status === "none" ? (
<img
src={getStaticImage(src)}
alt={alt}
width={width}
height={height}
draggable={false}
className="max-w-none select-none"
/>
) : (
<ImageButton
src={src}
alt={alt}
width={width}
height={height}
onClick={onClick}
/>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,6 @@
import type { MailStatus } from "@/stores/types/mail.types";
export interface MailEntryProps {
status: MailStatus;
onClick: () => void;
}
@@ -0,0 +1,90 @@
import { useEffect, useRef } from "react";
import { ImageButton } from "@/components/common";
import { getAppPortalRoot } from "@/components/common/canvasPortal";
import { Overlay } from "@/components/ui/overlay";
import { getStaticImage } from "@/utils/assets";
import type { MailPromptDialogProps } from "./MailPromptDialog.types";
// 底板原图 762 x 587,顶部约 90px 为「提示」标头,内容需避开
const BOARD_WIDTH = 762;
const BOARD_HEIGHT = 587;
export function MailPromptDialog({
open,
onView,
onClose,
}: MailPromptDialogProps) {
const viewRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
const animationFrame = requestAnimationFrame(() => {
viewRef.current?.focus();
});
return () => cancelAnimationFrame(animationFrame);
}, [open]);
return (
<Overlay
open={open}
onClose={onClose}
container={getAppPortalRoot}
contentClassName="flex items-center justify-center"
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="mail-prompt-title"
className="relative"
style={{ width: BOARD_WIDTH, height: BOARD_HEIGHT }}
>
<img
src={getStaticImage("main-scene/panels/底板-提示")}
alt=""
aria-hidden="true"
draggable={false}
className="pointer-events-none absolute inset-0 h-full w-full select-none"
/>
<div className="relative flex h-full flex-col items-center pt-28">
<img
src={getStaticImage("main-scene/letters/信图标")}
alt=""
aria-hidden="true"
width={214}
height={155}
draggable={false}
className="max-w-none select-none"
/>
<p
id="mail-prompt-title"
className="mt-9 text-[40px] font-bold tracking-[2px] text-[#5b4632]"
>
你有一条新邮件,是否查看
</p>
<div className="mb-22 mt-auto flex items-center gap-10">
<ImageButton
src="main-scene/buttons/按钮-稍后"
alt="稍后再看"
width={241}
height={81}
onClick={onClose}
/>
<ImageButton
ref={viewRef}
src="main-scene/buttons/按钮-查看"
alt="查看邮件"
width={241}
height={81}
onClick={onView}
/>
</div>
</div>
</div>
</Overlay>
);
}
@@ -0,0 +1,7 @@
export interface MailPromptDialogProps {
open: boolean;
/** 点「查看」,进入求助信 */
onView: () => void;
/** 点「稍后」或关闭,保持待接收状态 */
onClose: () => void;
}
+158
View File
@@ -0,0 +1,158 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { BackgroundContainer, ImageButton } from "@/components/common";
import { useCaseStore } from "@/stores/modules/case";
import { useEvidenceStore } from "@/stores/modules/evidence";
import { useMailStore } from "@/stores/modules/mail";
import type { MailStatus } from "@/stores/types/mail.types";
import { getStaticImage } from "@/utils";
import { CaseEntry } from "./components/CaseEntry";
import { CaseRegistrationDialog } from "./components/CaseRegistrationDialog";
import { EvidenceBox } from "./components/EvidenceBox";
import { HelpLetterDialog } from "./components/HelpLetterDialog";
import { MailEntry } from "./components/MailEntry";
import { MailPromptDialog } from "./components/MailPromptDialog";
// 演示版:进入主场景后延迟送达第一封求助信,用于展示「无邮件 -> 待接收」的过渡
const MAIL_DELIVERY_DELAY = 1500;
// 邮件弹窗流程:提示框 -> 求助信
type MailView = "closed" | "prompt" | "letter";
export default function MainScene() {
const navigate = useNavigate();
const caseStatus = useCaseStore((state) => state.status);
const registerCase = useCaseStore((state) => state.registerCase);
const storedMailStatus = useMailStore((state) => state.status);
// 案件登记流程只能在已接收求助信后触发,返回主场景时邮件固定为可回看状态。
const mailStatus: MailStatus =
caseStatus === "pending" || caseStatus === "registered"
? "received"
: storedMailStatus;
const evidenceBoxVisible = useMailStore((state) => state.evidenceBoxVisible);
const studyVideoWatched = useEvidenceStore(
(state) => state.studyVideoWatched,
);
const deliverMail = useMailStore((state) => state.deliverMail);
const receiveMail = useMailStore((state) => state.receiveMail);
const [view, setView] = useState<MailView>("closed");
const [caseDialogOpen, setCaseDialogOpen] = useState(false);
useEffect(() => {
const timer = setTimeout(deliverMail, MAIL_DELIVERY_DELAY);
return () => clearTimeout(timer);
}, [deliverMail]);
// 待接收先过一道提示框;已接收则直接回看信件
const handleEntryClick = useCallback(() => {
setView(mailStatus === "received" ? "letter" : "prompt");
}, [mailStatus]);
const handleReceive = useCallback(() => {
receiveMail();
setView("closed");
}, [receiveMail]);
const handleCaseEntryClick = useCallback(() => {
if (caseStatus === "pending") setCaseDialogOpen(true);
}, [caseStatus]);
const handleRegisterCase = useCallback(() => {
registerCase();
setCaseDialogOpen(false);
}, [registerCase]);
return (
<BackgroundContainer
src="main-scene/backgrounds/功能页背景"
className="h-full w-full"
>
{/* 侦探工作档案按钮 - 左上角 */}
<div className="absolute right-163 bottom-115">
<ImageButton
src="main-scene/panels/侦探工作档案"
alt="侦探工作档案"
width={212}
height={113}
/>
</div>
{/* 按钮-离开 */}
<div className="absolute right-115 bottom-130">
<ImageButton
src="components/button/按钮-离开"
alt="离开"
width={101}
height={101}
onClick={() => navigate("/reception")}
/>
</div>
{/* 案件信息板 */}
<div className="absolute left-26 top-64">
{caseStatus === "registered" ? (
<img
src={getStaticImage("main-scene/panels/案件信息板-色彩迷局")}
alt="当前案件:色彩迷局,调查中"
width={176}
height={179}
/>
) : (
<img
src={getStaticImage("main-scene/panels/案件信息板-暂无信息")}
alt="当前暂无案件"
width={137}
height={114}
/>
)}
</div>
{/* 邮件入口 - 三态:无邮件 / 待接收 / 已接收(槽位与「案件登记」对齐) */}
<div className="absolute left-104.5 bottom-98.5">
<MailEntry status={mailStatus} onClick={handleEntryClick} />
</div>
{/* 案件登记入口 - 待登记时浮出提醒卡片 */}
<div className="absolute left-130.5 bottom-99">
<CaseEntry status={caseStatus} onClick={handleCaseEntryClick} />
</div>
{/* 登记后显示新增舆情提醒 */}
{caseStatus === "registered" && (
<img
src={getStaticImage("main-scene/reminders/提醒-新增舆情")}
alt="新增舆情"
width={444}
height={605}
draggable={false}
className="absolute right-30 top-40 select-none"
/>
)}
{/* 证物箱 - 接收信件后从右侧滑入桌面;调查完成(学习视频已看)后收起 */}
<EvidenceBox
visible={evidenceBoxVisible && !studyVideoWatched}
onClick={() => navigate("/evidence")}
/>
<CaseRegistrationDialog
open={caseDialogOpen}
onRegister={handleRegisterCase}
onClose={() => setCaseDialogOpen(false)}
/>
<MailPromptDialog
open={view === "prompt"}
onView={() => setView("letter")}
onClose={() => setView("closed")}
/>
<HelpLetterDialog
open={view === "letter"}
received={mailStatus === "received"}
onClose={() => setView("closed")}
onReceive={handleReceive}
/>
</BackgroundContainer>
);
}
@@ -0,0 +1,70 @@
import { useEffect, useRef } from "react";
import { ImageButton } from "@/components/common";
import { getAppPortalRoot } from "@/components/common/canvasPortal";
import { Overlay } from "@/components/ui/overlay";
import { getStaticImage } from "@/utils/assets";
import type { InvestigationUnavailableDialogProps } from "./InvestigationUnavailableDialog.types";
// 提示底板原图 762 x 587
const BOARD_WIDTH = 762;
const BOARD_HEIGHT = 587;
export function InvestigationUnavailableDialog({
open,
onClose,
}: InvestigationUnavailableDialogProps) {
const closeRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
const animationFrame = requestAnimationFrame(() => {
closeRef.current?.focus();
});
return () => cancelAnimationFrame(animationFrame);
}, [open]);
return (
<Overlay
open={open}
onClose={onClose}
container={getAppPortalRoot}
contentClassName="flex items-center justify-center"
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="investigation-unavailable-title"
className="relative"
style={{ width: BOARD_WIDTH, height: BOARD_HEIGHT }}
>
<img
src={getStaticImage("main-scene/panels/底板-提示")}
alt=""
aria-hidden="true"
draggable={false}
className="pointer-events-none absolute inset-0 h-full w-full select-none"
/>
<h2
id="investigation-unavailable-title"
className="absolute inset-x-20 top-60 text-center text-[42px] font-bold tracking-[3px] text-[#5b4632]"
>
功能暂未开放,敬请期待
</h2>
<ImageButton
ref={closeRef}
src="main-scene/buttons/按钮-关闭"
alt="关闭提示"
aria-label="关闭提示"
width={129}
height={131}
className="absolute -right-7 -top-7"
onClick={onClose}
/>
</div>
</Overlay>
);
}
@@ -0,0 +1,4 @@
export interface InvestigationUnavailableDialogProps {
open: boolean;
onClose: () => void;
}
@@ -0,0 +1,135 @@
import {
type FormEvent,
type KeyboardEvent,
useEffect,
useRef,
useState,
} from "react";
import { ImageButton } from "@/components/common";
import { getAppPortalRoot } from "@/components/common/canvasPortal";
import { Overlay } from "@/components/ui/overlay";
import { getStaticImage } from "@/utils/assets";
import type { RegistrationDialogProps } from "./RegistrationDialog.types";
const INITIAL_LEVEL = "实习侦探" as const;
export function RegistrationDialog({
open,
onClose,
onCreate,
}: RegistrationDialogProps) {
const [name, setName] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const submitRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const normalizedName = name.trim();
useEffect(() => {
if (!open) return;
setName("");
const animationFrame = requestAnimationFrame(() => {
inputRef.current?.focus();
});
return () => cancelAnimationFrame(animationFrame);
}, [open]);
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!normalizedName) return;
onCreate({ name: normalizedName, level: INITIAL_LEVEL });
};
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== "Tab") return;
const focusableElements = [
inputRef.current,
submitRef.current,
closeRef.current,
].filter(
(element): element is HTMLButtonElement | HTMLInputElement =>
element !== null && !element.hasAttribute("disabled"),
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault();
lastElement?.focus();
} else if (!event.shiftKey && document.activeElement === lastElement) {
event.preventDefault();
firstElement?.focus();
}
};
return (
<Overlay
open={open}
onClose={onClose}
container={getAppPortalRoot}
contentClassName="flex items-center justify-center"
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="newcomer-registration-title"
aria-describedby="newcomer-registration-description"
className="relative h-252.5 w-339.25"
onKeyDown={handleKeyDown}
>
<img
src={getStaticImage("reception/components/新人档案登记")}
alt=""
aria-hidden="true"
draggable={false}
className="pointer-events-none absolute inset-0 h-full w-full select-none"
/>
<form onSubmit={handleSubmit}>
<label htmlFor="detective-name" className="sr-only">
侦探姓名
</label>
<input
ref={inputRef}
id="detective-name"
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="请输入你的名字"
autoComplete="name"
maxLength={20}
required
className="absolute left-114.5 top-127 h-15.5 w-153.75 rounded-[14px] border-0 bg-transparent px-6.25 pr-25 text-[36px] tracking-[2px] text-[#6f7782] outline-none placeholder:text-[#9ea2a6] placeholder:text-[36px]"
/>
<ImageButton
ref={submitRef}
type="submit"
src="reception/components/按钮-创建档案"
alt="创建档案"
width={487}
height={127}
disabled={!normalizedName}
className="absolute outline-none focus-visible:ring-4 focus-visible:ring-[#fff2ad]"
style={{ left: 451, top: 715 }}
/>
</form>
<div className="absolute right-0 top-0">
<ImageButton
ref={closeRef}
src="reception/components/按钮-关闭"
alt="关闭新人档案登记"
aria-label="关闭新人档案登记"
width={129}
height={131}
onClick={onClose}
/>
</div>
</div>
</Overlay>
);
}
@@ -0,0 +1,10 @@
export interface DetectiveProfileDraft {
name: string;
level: "实习侦探";
}
export interface RegistrationDialogProps {
open: boolean;
onClose: () => void;
onCreate: (profile: DetectiveProfileDraft) => void;
}
+114
View File
@@ -0,0 +1,114 @@
import { useCallback, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
BackgroundContainer,
ImageButton,
VideoOverlay,
} from "@/components/common";
import { useUserStore } from "@/stores/modules/user";
import { getPublicPath } from "@/utils/assets";
import { InvestigationUnavailableDialog } from "./components/InvestigationUnavailableDialog";
import { RegistrationDialog } from "./components/RegistrationDialog";
export default function Reception() {
const navigate = useNavigate();
const setDetectiveProfile = useUserStore(
(state) => state.setDetectiveProfile,
);
const [isRegistrationOpen, setIsRegistrationOpen] = useState(false);
const [isVideoOpen, setIsVideoOpen] = useState(false);
const [isInvestigationPromptOpen, setIsInvestigationPromptOpen] =
useState(false);
const registrationButtonRef = useRef<HTMLButtonElement>(null);
const handleCloseRegistration = useCallback(() => {
setIsRegistrationOpen(false);
requestAnimationFrame(() => registrationButtonRef.current?.focus());
}, []);
const handleCreateProfile = useCallback(
(profile: { name: string; level: string }) => {
// 保存档案信息到 store
setDetectiveProfile(profile);
// 关闭登记弹窗
setIsRegistrationOpen(false);
// 打开视频播放器
setIsVideoOpen(true);
},
[setDetectiveProfile],
);
const handleVideoEnded = useCallback(() => {
// 视频播放结束后关闭视频,进入主场景
setIsVideoOpen(false);
navigate("/main-scene");
}, [navigate]);
const handleCloseVideo = useCallback(() => {
setIsVideoOpen(false);
}, []);
const handleLeave = useCallback(() => {
navigate("/");
}, [navigate]);
return (
<BackgroundContainer
src="reception/底图-新人登记"
className="h-full w-full"
>
<div className="absolute left-166 top-24">
<ImageButton
ref={registrationButtonRef}
src="reception/新人登记"
alt="新人登记"
aria-label="打开新人档案登记"
aria-haspopup="dialog"
aria-expanded={isRegistrationOpen}
width={121}
height={66}
onClick={() => setIsRegistrationOpen(true)}
/>
</div>
<div className="mt-120 px-60 flex justify-between">
<ImageButton
src="components/button/按钮-离开"
alt="离开课程"
width={151}
height={151}
onClick={handleLeave}
/>
<ImageButton
src="reception/按钮-进入"
alt="进入调查"
aria-haspopup="dialog"
aria-expanded={isInvestigationPromptOpen}
width={199}
height={182}
onClick={() => setIsInvestigationPromptOpen(true)}
/>
</div>
<InvestigationUnavailableDialog
open={isInvestigationPromptOpen}
onClose={() => setIsInvestigationPromptOpen(false)}
/>
<RegistrationDialog
open={isRegistrationOpen}
onClose={handleCloseRegistration}
onCreate={handleCreateProfile}
/>
<VideoOverlay
open={isVideoOpen}
src={getPublicPath("video/Pt1_zhuosejikepudonghua.mp4")}
dismissible
lockScroll={false}
onClose={handleCloseVideo}
onEnded={handleVideoEnded}
/>
</BackgroundContainer>
);
}
+32
View File
@@ -0,0 +1,32 @@
import type { RecordPhase } from "@/stores/types/record.types";
export type RecordSpeaker = "detective" | "assistant";
/** 一条调查流水对话 */
export interface RecordLine {
id: string;
/** 属于哪个剧情阶段,阶段推进后才会放出 */
phase: RecordPhase;
speaker: RecordSpeaker;
text: string;
}
/** 桌面物件:随剧情阶段变化,点击进入对应子场景 */
export interface DeskItemData {
id: string;
phase: RecordPhase;
name: string;
/** 物件图片资源 key */
image: string;
/** 引导标签资源 key(悬在物件上方) */
guide: string;
/** 点击后进入的路由 */
to: string;
/** 以 1920 x 1080 设计稿为准的落位与尺寸 */
left: number;
bottom: number;
width: number;
height: number;
guideWidth: number;
guideHeight: number;
}
+75
View File
@@ -0,0 +1,75 @@
import { useLayoutEffect, useRef } from "react";
import { ImageButton } from "@/components/common";
import { getStaticImage } from "@/utils";
import { EASE_IN_OUT, EASE_OUT, gsap, useReducedMotion } from "@/utils/motion";
import type { DeskItemProps } from "./DeskItem.types";
export function DeskItem({ item, onClick }: DeskItemProps) {
const rootRef = useRef<HTMLDivElement>(null);
const guideRef = useRef<HTMLImageElement>(null);
const reduced = useReducedMotion();
// 物件登场:从桌面下方托起;引导标签持续上下轻浮,提示可点击
useLayoutEffect(() => {
const root = rootRef.current;
const guide = guideRef.current;
if (!root) return;
if (reduced) {
gsap.set(root, { opacity: 1, y: 0 });
return;
}
const enter = gsap.fromTo(
root,
{ opacity: 0, y: 60 },
{ opacity: 1, y: 0, duration: 0.6, ease: EASE_OUT },
);
const float = guide
? gsap.fromTo(
guide,
{ y: 0 },
{
y: -10,
duration: 0.9,
ease: EASE_IN_OUT,
repeat: -1,
yoyo: true,
delay: 0.6,
},
)
: null;
return () => {
enter.kill();
float?.kill();
};
}, [reduced]);
return (
<div
ref={rootRef}
className="pointer-events-auto absolute"
style={{ left: item.left, bottom: item.bottom }}
>
<img
ref={guideRef}
src={getStaticImage(item.guide)}
alt=""
aria-hidden="true"
draggable={false}
className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 max-w-none select-none"
style={{ width: item.guideWidth, height: item.guideHeight }}
/>
<ImageButton
src={item.image}
alt={item.name}
aria-label={`查看${item.name}`}
width={item.width}
height={item.height}
onClick={onClick}
/>
</div>
);
}
@@ -0,0 +1,6 @@
import type { DeskItemData } from "../Record.types";
export interface DeskItemProps {
item: DeskItemData;
onClick: () => void;
}
@@ -0,0 +1,101 @@
import { useLayoutEffect, useRef } from "react";
import { cn, getStaticImage } from "@/utils";
import { EASE_OUT, gsap, useReducedMotion } from "@/utils/motion";
import type { DialogueBubbleProps } from "./DialogueBubble.types";
const SPEAKERS = {
detective: {
tag: "record/称号标签-添侦探",
bubble: "record/添侦探.svg",
align: "left",
tagClassName: "left-10 h-18",
textClassName: "pl-12 pr-16",
},
assistant: {
tag: "record/称号标签-侦探助手",
bubble: "record/侦探助手.svg",
align: "right",
tagClassName: "right-14 h-20",
textClassName: "pl-10 pr-16",
},
} as const;
export function DialogueBubble({ line }: DialogueBubbleProps) {
const rootRef = useRef<HTMLDivElement>(null);
const reduced = useReducedMotion();
const speaker = SPEAKERS[line.speaker];
const fromRight = speaker.align === "right";
// 新消息从发言角色方向滑入,并带轻微回弹
useLayoutEffect(() => {
const node = rootRef.current;
if (!node) return;
if (reduced) {
gsap.set(node, { opacity: 1, x: 0, y: 0, scale: 1 });
return;
}
const tween = gsap.fromTo(
node,
{
opacity: 0,
x: fromRight ? 55 : -55,
y: 18,
scale: 0.92,
},
{
opacity: 1,
x: 0,
y: 0,
scale: 1,
duration: 0.55,
ease: EASE_OUT,
},
);
return () => {
tween.kill();
};
}, [fromRight, reduced]);
return (
<div
ref={rootRef}
className={cn(
"flex w-full flex-col",
fromRight ? "items-end" : "items-start",
)}
>
<div className="relative min-h-35 w-fit max-w-full shrink-0 ">
<img
src={getStaticImage(speaker.bubble)}
alt=""
aria-hidden="true"
draggable={false}
className="pointer-events-none absolute inset-0 h-full w-full max-w-none select-none"
/>
<img
src={getStaticImage(speaker.tag)}
alt=""
aria-hidden="true"
draggable={false}
className={cn(
"absolute -top-10 z-10 max-w-none select-none",
speaker.tagClassName,
)}
/>
<p
className={cn(
"relative flex h-full items-center whitespace-nowrap text-[28px] leading-normal text-[#5b4632]",
speaker.textClassName,
)}
>
{line.text}
</p>
</div>
</div>
);
}
@@ -0,0 +1,5 @@
import type { RecordLine } from "../Record.types";
export interface DialogueBubbleProps {
line: RecordLine;
}
@@ -0,0 +1,76 @@
import { useEffect, useMemo, useRef } from "react";
import { useRecordStore } from "@/stores/modules/record";
import { useReducedMotion } from "@/utils/motion";
import { PHASE_ORDER, RECORD_DIALOGUE } from "../record.data";
import { DialogueBubble } from "./DialogueBubble";
const FIRST_LINE_DELAY = 350;
const LINE_INTERVAL = 950;
export function DialogueFeed() {
const phase = useRecordStore((state) => state.phase);
const revealedCount = useRecordStore((state) => state.revealedCount);
const revealNextLine = useRecordStore((state) => state.revealNextLine);
const reduced = useReducedMotion();
const scrollRef = useRef<HTMLDivElement>(null);
const lines = useMemo(() => {
const reached = PHASE_ORDER.indexOf(phase);
return RECORD_DIALOGUE.filter(
(line) => PHASE_ORDER.indexOf(line.phase) <= reached,
);
}, [phase]);
const pending = revealedCount < lines.length;
useEffect(() => {
if (!pending) return;
const timer = setTimeout(
revealNextLine,
revealedCount === 0 ? FIRST_LINE_DELAY : LINE_INTERVAL,
);
return () => clearTimeout(timer);
}, [pending, revealedCount, revealNextLine]);
useEffect(() => {
const node = scrollRef.current;
if (!node || revealedCount === 0) return;
node.scrollTo({
top: node.scrollHeight,
behavior: reduced || revealedCount === 1 ? "auto" : "smooth",
});
}, [reduced, revealedCount]);
return (
<div
ref={scrollRef}
role="log"
aria-label="调查对话记录,可上下滑动查看"
aria-live="polite"
className="flex h-full touch-pan-y flex-col gap-8 overflow-y-auto overscroll-contain px-0 pt-12 pb-5 outline-none [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
style={{ WebkitOverflowScrolling: "touch" }}
>
{lines.slice(0, revealedCount).map((line) => (
<DialogueBubble key={line.id} line={line} />
))}
{pending && revealedCount > 0 && (
<div
className="flex items-center justify-center gap-3"
aria-hidden="true"
>
{[0, 1, 2].map((dot) => (
<span
key={dot}
className="h-3.5 w-3.5 animate-bounce rounded-full bg-[#fff0cf]"
style={{ animationDelay: `${dot * 0.16}s` }}
/>
))}
</div>
)}
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
import { useNavigate } from "react-router-dom";
import { BackgroundContainer, ImageButton } from "@/components/common";
import { useRecordStore } from "@/stores/modules/record";
import { getStaticImage } from "@/utils";
import { DeskItem } from "./components/DeskItem";
import { DialogueFeed } from "./components/DialogueFeed";
import { DESK_ITEMS } from "./record.data";
export default function Record() {
const navigate = useNavigate();
const phase = useRecordStore((state) => state.phase);
// 桌面物件随剧情阶段替换
const deskItems = DESK_ITEMS.filter((item) => item.phase === phase);
return (
<BackgroundContainer
src="record/对话全屏背景"
className="relative h-full w-full overflow-hidden"
contentClassName="absolute inset-0 overflow-hidden"
>
{/* 页面标题 */}
<img
src={getStaticImage("title/按钮-调查记录")}
alt="调查交流记录"
draggable={false}
className="absolute left-5 top-0 z-30 h-auto w-131 select-none"
/>
{/* 返回主场景 */}
<div className="absolute right-8 top-5 z-30">
<ImageButton
src="components/button/按钮-返回"
alt="返回主场景"
width={244}
height={98}
onClick={() => navigate("/main-scene")}
/>
</div>
{/* 对话角色位于桌板后方 */}
<img
src={getStaticImage("record/形象-添侦探")}
alt="添侦探"
draggable={false}
className="absolute bottom-0 left-27 z-10 h-222 select-none"
/>
<img
src={getStaticImage("record/形象-侦探助手")}
alt="实习侦探"
draggable={false}
className="absolute -right-12 -bottom-4 z-10 h-201 select-none"
/>
{/* 最新对话逐条浮现 */}
<section
aria-label="调查交流记录"
className="absolute left-142.5 top-90 z-20 h-105 w-262.5"
>
<DialogueFeed />
</section>
{/* 前景桌板与侦探档案盒,遮挡角色下半身 */}
<img
src={getStaticImage("record/对话全屏-置顶桌板")}
alt=""
aria-hidden="true"
draggable={false}
className="pointer-events-none absolute bottom-0 left-0 z-30 h-168 max-w-none select-none"
/>
{/* 桌面物件位于桌板之上 */}
<div className="pointer-events-none absolute bottom-16 z-40">
{deskItems.map((item) => (
<DeskItem
key={item.id}
item={item}
onClick={() => navigate(item.to)}
/>
))}
</div>
</BackgroundContainer>
);
}
+42
View File
@@ -0,0 +1,42 @@
import type { RecordPhase } from "@/stores/types/record.types";
import type { DeskItemData, RecordLine } from "./Record.types";
/** 剧情推进顺序,用于判断某阶段的内容是否已经解锁 */
export const PHASE_ORDER: RecordPhase[] = ["letter", "cola"];
export const RECORD_DIALOGUE: RecordLine[] = [
{
id: "d1",
phase: "letter",
speaker: "assistant",
text: "添侦探,我们的调查从哪里开始呢?",
},
{
id: "d2",
phase: "letter",
speaker: "detective",
text: "我们再来看看求助信",
},
];
/**
* 桌面物件按剧情阶段出现,尺寸按素材原始比例放大。
* 下一阶段的「可乐(点击标签进入核查)」在这里追加一条即可。
*/
export const DESK_ITEMS: DeskItemData[] = [
{
id: "help-letter",
phase: "letter",
name: "求助信",
image: "record/可点击-求助信",
guide: "record/引导-点击查看求助信",
to: "/letter-study",
// 可点击-求助信 原图 303 x 138,引导 350 x 167
left: 860,
bottom: -58,
width: 303,
height: 138,
guideWidth: 350,
guideHeight: 167,
},
];