feat: 更新音频和引导对话,优化用户体验
- 在多个场景中添加音频资源,增强学习互动性。 - 更新引导对话文本,确保信息准确性和一致性。 - 调整组件结构,清理不必要的代码,提升可维护性。 - 引入结束窗口,鼓励用户深入了解历史背景。
This commit is contained in:
1 parent
d7ff7d3a45
commit
6e106032fd
141 files changed
+1375
-479
No files matched your search
@@ -1,71 +0,0 @@
|
||||
import { Image } from "@/components";
|
||||
import { getStaticImage } from "@/lib/utils";
|
||||
import { ClueData } from "./types";
|
||||
import DialogueContent from "./DialogueContent";
|
||||
|
||||
export interface ClueContentProps {
|
||||
/** 线索数据 */
|
||||
clue: ClueData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 线索内容组件
|
||||
* 根据线索类型渲染不同的内容
|
||||
*/
|
||||
const ClueContent = ({ clue }: ClueContentProps) => {
|
||||
switch (clue.type) {
|
||||
case "text":
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center gap-4">
|
||||
<h2 className="text-4xl font-bold text-[#896a27] mb-4 text-center">
|
||||
{clue.title}
|
||||
</h2>
|
||||
{typeof clue.content === "string" ? (
|
||||
<div className="text-3xl leading-16 text-[#896a27] whitespace-pre-line">
|
||||
{clue.content}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-3xl leading-16 text-[#896a27]">
|
||||
{clue.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "map":
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Image
|
||||
src={getStaticImage(clue.mapImage)}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
alt="地图"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "audio":
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="text-5xl leading-relaxed text-white whitespace-pre-line text-center ">
|
||||
{clue.text}
|
||||
</div>
|
||||
<Image
|
||||
className="absolute left-0 bottom-10 w-full "
|
||||
src={getStaticImage(clue.audioImage)}
|
||||
alt="录音"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "dialogue":
|
||||
return <DialogueContent clue={clue} />;
|
||||
|
||||
case "custom":
|
||||
return <>{clue.content}</>;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default ClueContent;
|
||||
@@ -1,130 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Image } from "@/components";
|
||||
import { getStaticImage } from "@/lib/utils";
|
||||
import { DialogueClueData } from "./types";
|
||||
|
||||
export interface DialogueContentProps {
|
||||
/** 对话线索数据 */
|
||||
clue: DialogueClueData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对话内容组件
|
||||
* 依次显示多条对话,所有对话同时展示,带有动画效果
|
||||
*/
|
||||
const DialogueContent = ({ clue }: DialogueContentProps) => {
|
||||
const [visibleIndices, setVisibleIndices] = useState<number[]>([]);
|
||||
|
||||
// 依次显示对话:先显示第一条,延迟后显示第二条
|
||||
useEffect(() => {
|
||||
if (clue.dialogues.length === 0) return;
|
||||
|
||||
// 立即显示第一条对话
|
||||
setVisibleIndices([0]);
|
||||
|
||||
// 延迟显示后续对话
|
||||
if (clue.dialogues.length > 1) {
|
||||
const timer = setTimeout(() => {
|
||||
setVisibleIndices((prev) => [...prev, 1]);
|
||||
}, 1500); // 第一条显示1.5秒后显示第二条
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [clue.dialogues.length]);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center gap-30 relative px-10">
|
||||
{clue.dialogues.map((dialogue, index) => {
|
||||
const isVisible = visibleIndices.includes(index);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 30, scale: 0.95 }}
|
||||
animate={
|
||||
isVisible
|
||||
? { opacity: 1, y: 0, scale: 1 }
|
||||
: { opacity: 0, y: 30, scale: 0.95 }
|
||||
}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
ease: [0.16, 1, 0.3, 1], // 自定义缓动函数,更流畅
|
||||
}}
|
||||
className="flex items-center gap-8 w-full max-w-7xl"
|
||||
>
|
||||
{/* 角色头像 */}
|
||||
{dialogue.avatar && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={
|
||||
isVisible ? { opacity: 1, x: 0 } : { opacity: 0, x: -20 }
|
||||
}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
delay: 0.1,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
className="flex-shrink-0 flex flex-col items-center"
|
||||
>
|
||||
<Image
|
||||
src={getStaticImage(dialogue.avatar)}
|
||||
className="w-60 h-60 object-contain"
|
||||
alt={dialogue.character}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={
|
||||
isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 10 }
|
||||
}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
delay: 0.3,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
className="mt-3 px-5 py-2 bg-gray-800/90 rounded-lg"
|
||||
>
|
||||
<span className="text-white text-base font-medium">
|
||||
{dialogue.character}
|
||||
</span>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 对话气泡 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={isVisible ? { opacity: 1, x: 0 } : { opacity: 0, x: 20 }}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
delay: 0.2,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
className="flex-1 relative"
|
||||
>
|
||||
<div className="bg-gray-800/95 rounded-3xl px-20 py-8 shadow-2xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={isVisible ? { opacity: 1 } : { opacity: 0 }}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
delay: 0.4,
|
||||
}}
|
||||
className="text-white text-4xl leading-relaxed"
|
||||
>
|
||||
{dialogue.text}
|
||||
</motion.div>
|
||||
</div>
|
||||
{/* 对话气泡箭头 - 指向左侧头像 */}
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-3">
|
||||
<div className="w-0 h-0 border-t-[12px] border-t-transparent border-r-[12px] border-r-gray-800/95 border-b-[12px] border-b-transparent" />
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DialogueContent;
|
||||
@@ -1,167 +0,0 @@
|
||||
import { useEffect, useState, useMemo, MouseEvent, ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { cn, getStaticImage } from "@/lib/utils";
|
||||
import { Image } from "@/components";
|
||||
import { useScale } from "@/contexts/ScaleContext";
|
||||
import ClueContent from "./ClueContent";
|
||||
import { ClueData } from "./types";
|
||||
|
||||
export interface ClueOverlayProps {
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void;
|
||||
/** 线索数据(如果提供,会优先使用) */
|
||||
clue?: ClueData;
|
||||
/** 自定义内容(文本或其他内容,当 clue 未提供时使用) */
|
||||
children?: ReactNode;
|
||||
/** 额外的自定义类 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 线索展示框组件
|
||||
* - 中间内容区域可自定义(文本或其他内容)
|
||||
* - 使用背景资源:components/layout/纸(地图和音频类型除外)
|
||||
*/
|
||||
const ClueOverlay = ({
|
||||
visible = true,
|
||||
onClose,
|
||||
clue,
|
||||
children,
|
||||
className,
|
||||
}: ClueOverlayProps) => {
|
||||
const maskSrc = getStaticImage("components/蒙版遮罩");
|
||||
const backSrc = getStaticImage("components/关闭");
|
||||
const paperBgSrc = getStaticImage("components/layout/纸");
|
||||
|
||||
// 获取缩放参数,用于视觉对齐
|
||||
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// 判断是否需要显示纸张背景(地图、音频和对话类型不显示纸张背景)
|
||||
const shouldShowPaperBg =
|
||||
clue?.type !== "map" && clue?.type !== "audio" && clue?.type !== "dialogue";
|
||||
|
||||
// SSR 兼容:只在客户端挂载
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const handleClose = (e: MouseEvent<HTMLImageElement>) => {
|
||||
e.stopPropagation();
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
// 视觉对齐样式:确保与 App 内容对齐
|
||||
const overlayStyle = useMemo<React.CSSProperties>(
|
||||
() => ({
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 9999,
|
||||
pointerEvents: "none",
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const scaledContainerStyle = useMemo<React.CSSProperties>(
|
||||
() => ({
|
||||
position: "absolute",
|
||||
width: `${DESIGN_WIDTH}px`,
|
||||
height: `${DESIGN_HEIGHT}px`,
|
||||
left: `${offsetX}px`,
|
||||
top: `${offsetY}px`,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "top left",
|
||||
overflow: "hidden",
|
||||
}),
|
||||
[scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT]
|
||||
);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const content = (
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<motion.div
|
||||
key="clue-overlay"
|
||||
style={overlayStyle}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
>
|
||||
<div style={scaledContainerStyle}>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 flex items-center justify-center",
|
||||
"bg-black/70",
|
||||
className
|
||||
)}
|
||||
style={{ pointerEvents: "auto" }}
|
||||
>
|
||||
{/* 背景蒙版 */}
|
||||
{maskSrc && (
|
||||
<Image
|
||||
src={maskSrc}
|
||||
className="absolute inset-0 w-full h-full object-cover select-none pointer-events-none"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
{onClose && (
|
||||
<Image
|
||||
src={backSrc}
|
||||
onClick={handleClose}
|
||||
alt="关闭"
|
||||
className="absolute top-5 right-5 z-20"
|
||||
button
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="relative z-10 flex items-center justify-center w-full h-full">
|
||||
{shouldShowPaperBg ? (
|
||||
// 文本类型:显示纸张背景
|
||||
<div
|
||||
className="relative"
|
||||
style={{ minWidth: "600px", minHeight: "400px" }}
|
||||
>
|
||||
{/* 纸张背景图片 */}
|
||||
<Image
|
||||
src={paperBgSrc}
|
||||
className="w-full h-full object-contain pointer-events-none select-none"
|
||||
alt=""
|
||||
aria-hidden
|
||||
/>
|
||||
{/* 自定义内容 - 绝对定位在纸张上方 */}
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
style={{ padding: "60px 80px" }}
|
||||
>
|
||||
<div className="w-full h-full flex flex-col items-center justify-center">
|
||||
{clue ? <ClueContent clue={clue} /> : children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 地图和音频类型:直接显示内容,无纸张背景
|
||||
<div className="max-w-full max-h-full w-full flex items-center justify-center">
|
||||
{clue ? <ClueContent clue={clue} /> : children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
// 直接使用 Portal 渲染到 document.body
|
||||
return createPortal(content, document.body);
|
||||
};
|
||||
|
||||
export default ClueOverlay;
|
||||
@@ -1,58 +0,0 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
/** 线索类型 */
|
||||
export type ClueType = "text" | "map" | "audio" | "dialogue" | "custom";
|
||||
|
||||
/** 文本线索数据 */
|
||||
export interface TextClueData {
|
||||
type: "text";
|
||||
/** 标题 */
|
||||
title: string;
|
||||
/** 内容文本(支持字符串或 ReactNode,字符串可使用 \n 换行) */
|
||||
content: string | ReactNode;
|
||||
}
|
||||
|
||||
/** 地图线索数据 */
|
||||
export interface MapClueData {
|
||||
type: "map";
|
||||
/** 地图图片资源路径 */
|
||||
mapImage: string;
|
||||
}
|
||||
|
||||
/** 录音线索数据 */
|
||||
export interface AudioClueData {
|
||||
type: "audio";
|
||||
/** 文本内容 */
|
||||
text: string;
|
||||
/** 录音图片资源路径 */
|
||||
audioImage: string;
|
||||
}
|
||||
|
||||
/** 对话线索数据 */
|
||||
export interface DialogueClueData {
|
||||
type: "dialogue";
|
||||
/** 对话列表 */
|
||||
dialogues: Array<{
|
||||
/** 角色名称 */
|
||||
character: string;
|
||||
/** 角色头像资源路径 */
|
||||
avatar?: string;
|
||||
/** 对话文本 */
|
||||
text: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** 自定义线索数据 */
|
||||
export interface CustomClueData {
|
||||
type: "custom";
|
||||
/** 自定义内容 */
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
/** 线索数据联合类型 */
|
||||
export type ClueData =
|
||||
| TextClueData
|
||||
| MapClueData
|
||||
| AudioClueData
|
||||
| DialogueClueData
|
||||
| CustomClueData;
|
||||
@@ -1,6 +1,6 @@
|
||||
export { default as ResidenceLayout } from "./ResidenceLayout";
|
||||
export { default as ClueOverlay } from "./clue";
|
||||
export type { ClueOverlayProps } from "./clue";
|
||||
export { default as ClueOverlay } from "../../pages/chenyun/reflection/clue";
|
||||
export type { ClueOverlayProps } from "../../pages/chenyun/reflection/clue";
|
||||
export { default as Alert } from "./alert/title-alert";
|
||||
export type { AlertProps } from "./alert/title-alert";
|
||||
export { default as SuccessAlert } from "./alert/success-alert";
|
||||
@@ -9,12 +9,12 @@ export { default as ErrorAlert } from "./alert/error-alert";
|
||||
export type { ErrorAlertProps } from "./alert/error-alert";
|
||||
export { DragQuizPanel } from "./quiz";
|
||||
export type { DragQuizPanelProps } from "./quiz";
|
||||
export type {
|
||||
ClueData,
|
||||
ClueType,
|
||||
TextClueData,
|
||||
MapClueData,
|
||||
AudioClueData,
|
||||
DialogueClueData,
|
||||
CustomClueData,
|
||||
} from "./clue/types";
|
||||
// export type {
|
||||
// ClueData,
|
||||
// ClueType,
|
||||
// TextClueData,
|
||||
// MapClueData,
|
||||
// AudioClueData,
|
||||
// DialogueClueData,
|
||||
// CustomClueData,
|
||||
// } from "../../pages/chenyun/reflection/clue/types";
|
||||
@@ -90,8 +90,12 @@ const MultipleChoiceQuizPanel = ({
|
||||
onSubmit?.(submittedValues, true);
|
||||
} else {
|
||||
// 答题失败:清空状态,执行 onError,允许重新选择
|
||||
const submittedValues = [...selectedValues];
|
||||
setSelectedValues([]);
|
||||
const errorMsg = errorMessage || "很抱歉,你选错了!请你再思考一下吧!";
|
||||
const errorMsg =
|
||||
typeof errorMessage === "function"
|
||||
? errorMessage(submittedValues)
|
||||
: errorMessage || "很抱歉,你选错了!请你再思考一下吧!";
|
||||
setError(errorMsg);
|
||||
onError?.(errorMsg);
|
||||
}
|
||||
|
||||
@@ -65,8 +65,8 @@ export interface MultipleChoiceQuizPanelProps {
|
||||
options: MultipleChoiceOption[];
|
||||
/** 正确答案数组(可选,用于验证) */
|
||||
correctAnswer?: string[];
|
||||
/** 自定义错误提示信息(答案错误时显示) */
|
||||
errorMessage?: string;
|
||||
/** 自定义错误提示信息(答案错误时显示),可以是字符串或函数(根据用户选择返回不同提示) */
|
||||
errorMessage?: string | ((selectedValues: string[]) => string);
|
||||
/** 自定义最小选择数量不足时的提示信息 */
|
||||
minSelectionsErrorMessage?: string;
|
||||
/** 提交回调,返回用户选择和是否正确 */
|
||||
|
||||
Reference in new issue
Block a user