- 更新展品图片(鲁迅/毛泽东/孙中山/黄炎培/聂耳/张乐平故居) - 新增点击查看图标、报纸素材 - 优化视频组件、拼图组件、展品组件 - 更新陈云音频资源 - 新增输入验证文档和工具函数 - 组件布局和动画优化 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
379 lines
14 KiB
TypeScript
379 lines
14 KiB
TypeScript
import { useState, useEffect, useMemo, useCallback, useRef } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import { AnimatePresence, motion } from 'framer-motion'
|
|
import { cn, getStaticImage, getPublicPath } from '@/lib/utils'
|
|
import { Image, Video, DragFill } from '@/components'
|
|
import type { DragFillResult } from '@/components'
|
|
import { useScale } from '@/contexts/ScaleContext'
|
|
import type { VideoRef } from '@/components/video'
|
|
|
|
export interface ViseAssemblyDialogProps {
|
|
/** 是否可见 */
|
|
visible?: boolean
|
|
/** 关闭回调 */
|
|
onClose?: () => void
|
|
/** 完成回调 */
|
|
onComplete?: () => void
|
|
}
|
|
|
|
type PartType = 'A' | 'B' | 'C'
|
|
|
|
/**
|
|
* 组装台钳互动弹窗
|
|
* - 顶部三个位置用于放置零件
|
|
* - 底部三个零件(A、B、C)可以选择
|
|
* - 答题成功后播放视频
|
|
*/
|
|
const ViseAssemblyDialog = ({ visible = true, onClose, onComplete }: ViseAssemblyDialogProps) => {
|
|
const maskSrc = getStaticImage('components/蒙版遮罩')
|
|
const backSrc = getStaticImage('components/关闭')
|
|
|
|
// 零件背景提示素材
|
|
const partHintA = getStaticImage('huangyanpei/reflection/scene3/11_005')
|
|
const partHintB = getStaticImage('huangyanpei/reflection/scene3/11_006')
|
|
const partHintC = getStaticImage('huangyanpei/reflection/scene3/c')
|
|
|
|
// 零件素材
|
|
const partA = getStaticImage('huangyanpei/reflection/scene3/11_010')
|
|
const partB = getStaticImage('huangyanpei/reflection/scene3/11_011')
|
|
const partC = getStaticImage('huangyanpei/reflection/scene3/11_012')
|
|
|
|
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale()
|
|
const [mounted, setMounted] = useState(false)
|
|
const [value, setValue] = useState<DragFillResult[]>([])
|
|
const [videoVisible, setVideoVisible] = useState(false)
|
|
const videoRef = useRef<VideoRef>(null)
|
|
const completedRef = useRef(false)
|
|
|
|
// SSR 兼容
|
|
useEffect(() => {
|
|
setMounted(true)
|
|
}, [])
|
|
|
|
// 重置游戏状态
|
|
useEffect(() => {
|
|
if (visible) {
|
|
setValue([])
|
|
completedRef.current = false
|
|
setVideoVisible(false)
|
|
}
|
|
}, [visible])
|
|
|
|
const handleClose = useCallback(
|
|
(e: React.MouseEvent<HTMLImageElement>) => {
|
|
e.stopPropagation()
|
|
if (!videoVisible) {
|
|
onClose?.()
|
|
}
|
|
},
|
|
[onClose, videoVisible]
|
|
)
|
|
|
|
// 转换为 DragFill 的 options
|
|
const options = useMemo(
|
|
() => [
|
|
{ value: 'A', label: '丝杠和丝杆' },
|
|
{ value: 'B', label: '活动钳口' },
|
|
{ value: 'C', label: '固定钳口和基座' },
|
|
],
|
|
[]
|
|
)
|
|
|
|
// 转换为 DragFill 的 blanks
|
|
const blanks = useMemo(
|
|
() => [
|
|
{ id: 'slot0', label: '位置1' },
|
|
{ id: 'slot1', label: '位置2' },
|
|
{ id: 'slot2', label: '位置3' },
|
|
],
|
|
[]
|
|
)
|
|
|
|
// 处理拖拽结果变化
|
|
const handleValueChange = useCallback((newValue: DragFillResult[]) => {
|
|
// 验证:只允许 A->slot0, B->slot1, C->slot2
|
|
const filteredValue = newValue.filter((result) => {
|
|
const expectedSlot =
|
|
result.optionValue === 'A' ? 'slot0' : result.optionValue === 'B' ? 'slot1' : 'slot2'
|
|
return result.blankId === expectedSlot
|
|
})
|
|
setValue(filteredValue)
|
|
}, [])
|
|
|
|
// 处理"拼接台钳"按钮点击
|
|
const handleAssemble = useCallback(() => {
|
|
if (value.length < 3) {
|
|
return
|
|
}
|
|
|
|
const slot0 = value.find((r) => r.blankId === 'slot0')
|
|
const slot1 = value.find((r) => r.blankId === 'slot1')
|
|
const slot2 = value.find((r) => r.blankId === 'slot2')
|
|
|
|
// 验证:A->slot0, B->slot1, C->slot2
|
|
const isCorrect =
|
|
slot0?.optionValue === 'A' && slot1?.optionValue === 'B' && slot2?.optionValue === 'C'
|
|
|
|
if (isCorrect) {
|
|
completedRef.current = true
|
|
// 答题成功,播放视频
|
|
setVideoVisible(true)
|
|
} else {
|
|
// 答题失败,清除错误的填充
|
|
setTimeout(() => {
|
|
setValue([])
|
|
}, 1500)
|
|
alert('组装顺序不正确,请重新尝试!')
|
|
}
|
|
}, [value])
|
|
|
|
const handleVideoEnd = useCallback(() => {
|
|
setVideoVisible(false)
|
|
onComplete?.()
|
|
onClose?.()
|
|
}, [onComplete, onClose])
|
|
|
|
// 样式
|
|
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="vise-assembly-dialog"
|
|
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 flex-col items-center justify-center',
|
|
'bg-black/70'
|
|
)}
|
|
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 && !videoVisible && (
|
|
<Image
|
|
src={backSrc}
|
|
onClick={handleClose}
|
|
alt="返回"
|
|
className="absolute top-5 right-5 z-20"
|
|
button
|
|
/>
|
|
)}
|
|
|
|
{/* 视频播放层 */}
|
|
{videoVisible ? (
|
|
<div className="fixed inset-0 z-[10000] bg-black">
|
|
<Video
|
|
ref={videoRef}
|
|
src={getPublicPath('video/huangyanpei/huangyanpei_11.mp4')}
|
|
className="w-full h-full"
|
|
autoPlay
|
|
playsInline
|
|
preload="auto"
|
|
showSkipButton
|
|
skipKey="huangyanpei_reflection_vise_video"
|
|
showReadonlyProgress
|
|
skipText="跳过"
|
|
onEnded={handleVideoEnd}
|
|
/>
|
|
</div>
|
|
) : (
|
|
/* 内容区域 */
|
|
<div className="relative z-10 w-full h-full flex flex-col items-center px-16 pt-30 pb-8">
|
|
{/* 顶部三个位置 - 更大的框 */}
|
|
<div className="w-full flex items-center justify-between">
|
|
{[0, 1, 2].map((index) => {
|
|
const blankId = `slot${index}`
|
|
const filled = value.find((r) => r.blankId === blankId)
|
|
const partType = filled ? (filled.optionValue as PartType) : null
|
|
const hasPart = partType !== null
|
|
|
|
return (
|
|
<div
|
|
key={index}
|
|
data-blank-id={blankId}
|
|
className={cn(
|
|
'relative w-140 h-90 border-4 border-white rounded-lg transition-all duration-200',
|
|
hasPart ? 'bg-white/30' : 'bg-white/20 hover:bg-white/30'
|
|
)}
|
|
>
|
|
{/* 背景提示 */}
|
|
{!hasPart && index === 0 && (
|
|
<img
|
|
src={partHintA}
|
|
alt="位置A提示"
|
|
className="absolute inset-0 w-full h-full object-contain opacity-40 p-4"
|
|
/>
|
|
)}
|
|
{!hasPart && index === 1 && (
|
|
<img
|
|
src={partHintB}
|
|
alt="位置B提示"
|
|
className="absolute inset-0 w-full h-full object-contain opacity-40 p-4"
|
|
/>
|
|
)}
|
|
{!hasPart && index === 2 && (
|
|
<img
|
|
src={partHintC}
|
|
alt="位置C提示"
|
|
className="absolute inset-0 w-full h-full object-contain opacity-40 p-4"
|
|
/>
|
|
)}
|
|
{/* 已放置的零件 */}
|
|
{partType === 'A' && (
|
|
<img
|
|
src={partA}
|
|
alt="零件A"
|
|
className="absolute inset-0 w-full h-full object-contain p-4"
|
|
/>
|
|
)}
|
|
{partType === 'B' && (
|
|
<img
|
|
src={partB}
|
|
alt="零件B"
|
|
className="absolute inset-0 w-full h-full object-contain p-4"
|
|
/>
|
|
)}
|
|
{partType === 'C' && (
|
|
<img
|
|
src={partC}
|
|
alt="零件C"
|
|
className="absolute inset-0 w-full h-full object-contain p-4"
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* 中间的"拼接台钳"按钮 */}
|
|
<button
|
|
onClick={handleAssemble}
|
|
disabled={value.length < 3}
|
|
className="mt-8 px-20 py-3 bg-yellow-400 border-8 border-white rounded-xl text-[#432800] text-5xl font-bold cursor-pointer transition-all duration-200 hover:bg-yellow-500 hover:scale-105 active:scale-95 disabled:bg-white/50 disabled:cursor-not-allowed"
|
|
>
|
|
拼接台钳
|
|
</button>
|
|
|
|
{/* 底部零件选择区域 - 使用 DragFill */}
|
|
<div className="w-full gap-16">
|
|
<div className="text-[#432800] text-5xl font-bold mr-4 my-10">零件区</div>
|
|
<div className="flex justify-between">
|
|
<DragFill
|
|
options={options}
|
|
blanks={blanks}
|
|
value={value}
|
|
onChange={handleValueChange}
|
|
allowDuplicate={false}
|
|
layout="horizontal-options"
|
|
hideLabels={true}
|
|
showSubmitButton={false}
|
|
className="w-full"
|
|
renderOption={(option, isUsed) => {
|
|
const partSrc =
|
|
option.value === 'A' ? partA : option.value === 'B' ? partB : partC
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'flex items-center justify-center transition-all duration-200',
|
|
'cursor-move select-none',
|
|
isUsed
|
|
? 'opacity-30 cursor-not-allowed'
|
|
: 'hover:scale-110 opacity-100'
|
|
)}
|
|
>
|
|
<div className="flex flex-col gap-y-4">
|
|
<div className="flex items-center justify-center">
|
|
<div className="mr-16 text-8xl font-bold text-[#cf9e51] [text-shadow:_3px_3px_0_#fff,_-3px_-3px_0_#fff,_3px_-3px_0_#fff,_-3px_3px_0_#fff]">
|
|
{option.value}
|
|
</div>
|
|
<img
|
|
src={partSrc}
|
|
alt={`零件${option.value}`}
|
|
className="w-56 h-56 object-contain pointer-events-none"
|
|
draggable={false}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-center">
|
|
<div className="mr-16 text-3xl font-bold text-white">
|
|
{option.label}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}}
|
|
renderDragging={(option) => {
|
|
const partSrc =
|
|
option.value === 'A' ? partA : option.value === 'B' ? partB : partC
|
|
return (
|
|
<div className="w-56 h-56">
|
|
<img
|
|
src={partSrc}
|
|
alt={`零件${option.value}`}
|
|
className="w-full h-full object-contain"
|
|
draggable={false}
|
|
/>
|
|
</div>
|
|
)
|
|
}}
|
|
renderBlank={() => {
|
|
// 返回隐藏元素,实际的空白区域在页面中通过 data-blank-id 定位
|
|
return <div className="hidden" />
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
)
|
|
|
|
return createPortal(content, document.body)
|
|
}
|
|
|
|
export default ViseAssemblyDialog
|