feat: 食品添加剂课程业务开发(接待/主场景/取证/记录/信函学习页面)
This commit is contained in:
1 parent
ad37dfeb24
commit
bf0c194d16
192 files changed
+4675
-624
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
Reference in new issue
Block a user