import { useCallback, useMemo, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { motion } from 'framer-motion' import { Image, Title, SceneHint, DragFill, ExhibitFlipOverlay, FlipActionButton, } from '@/components' import type { DragFillResult } from '@/components' import { cn, getBackgroundImage, getStaticImage } from '@/lib/utils' import { downloadDataUrlImage } from '@/lib/utils/sensitiveWords' import { useZhanglepingStore, saveZhanglepingStore } from '@/store/zhanglepingStore' import { useSceneHintController } from './useSceneHintController' import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext' const starName = 'zhangleping' // 放置碎片(释放区域) const PLACE_ITEMS = [ { id: 'clue-1', src: 'zhangleping/reflection/clue/内嵌基底1' }, { id: 'clue-2', src: 'zhangleping/reflection/clue/内嵌基底2' }, { id: 'clue-3', src: 'zhangleping/reflection/clue/内嵌基底3' }, { id: 'clue-4', src: 'zhangleping/reflection/clue/内嵌基底4' }, ] as const // 拖拽碎片 const DRAG_ITEMS = [ { id: 'clue-1', className: 'absolute right-140 top-130', src: 'zhangleping/reflection/clue/碎片1', }, { id: 'clue-2', className: 'absolute right-62 bottom-25', src: 'zhangleping/reflection/clue/碎片2', }, { id: 'clue-3', className: 'absolute right-25 top-100', src: 'zhangleping/reflection/clue/碎片3', }, { id: 'clue-4', className: 'absolute right-100 top-60', src: 'zhangleping/reflection/clue/碎片4', }, ] as const type DragId = (typeof DRAG_ITEMS)[number]['id'] const DRAG_ASSET_MAP: Record = DRAG_ITEMS.reduce( (acc, item) => { acc[item.id] = { className: item.className, src: item.src } return acc }, {} as Record ) const DRAG_OPTIONS = DRAG_ITEMS.map((i) => ({ value: i.id, label: i.id })) const DRAG_BLANKS = PLACE_ITEMS.map((i) => ({ id: i.id, label: '', required: true })) const DRAG_ERROR_HINT = '碎片与基底不匹配,请放到正确的位置!' const PERSISTENT_DRAG_HINT = '将右侧记忆碎片拖拽到左侧对应的基底上,拼合完整记忆。' const DRAG_ERROR_HINT_DURATION_MS = 3200 const DRAG_ERROR_HINT_RESET_DELAY_MS = 3300 const ZhanglepingReflectionScene5 = () => { const navigate = useNavigate() const { moduleName } = useParams<{ moduleName: string }>() const [haloHovered, setHaloHovered] = useState(false) const scene = 'scene5' const total = 1 const { markReflectionCursorVisited, getVisitedReflectionCursors, drawArtworkDataUrl, hasDrawArtwork, } = useZhanglepingStore() const { triggerPuzzleAnimation } = usePuzzleAnimation() // 根据 store 中碎片是否已解锁决定初始子场景 const fragmentAlreadyUnlocked = getVisitedReflectionCursors(scene).includes(1) const [subScene, setSubScene] = useState<1 | 2 | 3>(fragmentAlreadyUnlocked ? 3 : 1) const progressCount = useMemo( () => getVisitedReflectionCursors(scene).length, [getVisitedReflectionCursors] ) const completed = progressCount >= total const subtitle = completed ? '已完成' : `${Math.min(progressCount, total)}/${total}` const hasSubmittedArtwork = hasDrawArtwork || (typeof drawArtworkDataUrl === 'string' && /^data:image\/[a-zA-Z]+;base64,/.test(drawArtworkDataUrl)) // 子场景2:拖拽状态 const [dragFillValue, setDragFillValue] = useState([]) const [showFragmentOverlay, setShowFragmentOverlay] = useState(false) const [allPlaced, setAllPlaced] = useState(false) const [isDragging, setIsDragging] = useState(false) const handleOptionPointerDown = useCallback(() => { setIsDragging(true) const handleUp = () => { setIsDragging(false) document.removeEventListener('pointerup', handleUp) document.removeEventListener('pointercancel', handleUp) } document.addEventListener('pointerup', handleUp) document.addEventListener('pointercancel', handleUp) }, []) const isDragActive = subScene === 2 && !allPlaced const hint = useSceneHintController(isDragActive, PERSISTENT_DRAG_HINT) const getBlankFilledSrc = useCallback( (blankId: string) => { const result = dragFillValue.find((r) => r.blankId === blankId) if (!result) return null return DRAG_ASSET_MAP[result.optionValue as DragId]?.src ?? null }, [dragFillValue] ) const handleDragValueChange = useCallback( (newValue: DragFillResult[]) => { if (!isDragActive) return const prev = dragFillValue const changed = newValue.filter( (nv) => !prev.some((pv) => pv.optionValue === nv.optionValue && pv.blankId === nv.blankId) ) // 检查新放入的是否匹配(id 必须一致) for (const entry of changed) { if (entry.optionValue !== entry.blankId) { // 不匹配:只保留之前正确的 setDragFillValue(prev) hint.showTempHint( DRAG_ERROR_HINT, DRAG_ERROR_HINT_DURATION_MS, DRAG_ERROR_HINT_RESET_DELAY_MS ) return } } // 只保留匹配的 const filtered = newValue.filter((v) => v.optionValue === v.blankId) setDragFillValue(filtered) // 全部放好 if (filtered.length === PLACE_ITEMS.length) { hint.clearAndReset() markReflectionCursorVisited(scene, 1) saveZhanglepingStore() setShowFragmentOverlay(true) } }, [dragFillValue, hint, isDragActive, markReflectionCursorVisited] ) const handleCloseFragmentOverlay = useCallback(() => { setShowFragmentOverlay(false) setAllPlaced(true) triggerPuzzleAnimation(14) // 短暂展示插入碎片后效果,然后进入画板场景 setTimeout(() => setSubScene(3), 800) }, [triggerPuzzleAnimation]) const handleDownloadArtwork = useCallback(async () => { if (!hasSubmittedArtwork) return try { await downloadDataUrlImage(drawArtworkDataUrl, '三毛新时代图景.png') } catch (error) { console.error('Download artwork failed', error) } }, [drawArtworkDataUrl, hasSubmittedArtwork]) if (!moduleName) return null const subSceneBg = { 1: '场景5', 2: '邮箱', 3: '画板' }[subScene] return (
{!showFragmentOverlay && ( <Image className="absolute right-5 top-5 z-10" src={getStaticImage('components/返回')} button onClick={() => navigate(`/${moduleName}/${starName}/reflection`, { state: { fromReflectionSubpage: true }, }) } /> )} {/* 子场景1:找寻碎片,点击光圈进入邮箱 */} {subScene === 1 && ( <> <SceneHint when persistent> 点击画面,寻找最终的碎片 </SceneHint> <motion.div className="absolute left-178 bottom-10 cursor-pointer" animate={haloHovered ? { scale: 1 } : { scale: [1, 0.8, 1] }} transition={ haloHovered ? { duration: 0.3, ease: 'easeOut' } : { duration: 1.5, repeat: Infinity, ease: 'easeInOut' } } onHoverStart={() => setHaloHovered(true)} onHoverEnd={() => setHaloHovered(false)} > <Image button src={getStaticImage('zhangleping/reflection/scene5/光圈')} onClick={() => setSubScene(2)} /> </motion.div> </> )} {/* 子场景2:邮箱场景(拖拽碎片任务) */} {subScene === 2 && ( <> {/* 拖拽提示 */} {!showFragmentOverlay && !allPlaced && !isDragging && hint.hintMode && ( <SceneHint when triggerKey={hint.hintTriggerKey} offset={{ x: -20 }} durationMs={hint.hintMode === 'temp' ? hint.hintDurationMs : undefined} persistent={hint.hintMode === 'persistent' ? true : undefined} > {hint.hintText} </SceneHint> )} {/* 插入碎片前/后 */} <div className="absolute left-100 top-32"> <Image src={getStaticImage( `zhangleping/reflection/scene5/${allPlaced ? '插入碎片后' : '插入碎片前'}` )} /> </div> {/* 放置碎片区域(自定义释放区域) */} <div className="absolute left-70 top-105 w-131 h-105 pl-10 pt-10"> <div className="relative w-full h-full grid grid-cols-2 gap-x-10"> {PLACE_ITEMS.map((item) => { const filledSrc = getBlankFilledSrc(item.id) return ( <div key={item.id} data-blank-id={item.id} className={cn( 'relative flex items-center justify-center transition-all', filledSrc ? 'opacity-100' : 'opacity-80' )} > <Image objectFit="contain" src={getStaticImage(item.src)} /> {filledSrc && ( <Image className="absolute inset-0 w-full h-full" objectFit="contain" src={getStaticImage(filledSrc)} /> )} </div> ) })} </div> </div> {/* DragFill 拖拽交互 */} {!allPlaced && ( <DragFill options={DRAG_OPTIONS} blanks={DRAG_BLANKS} value={dragFillValue} onChange={handleDragValueChange} showSubmitButton={false} hideLabels layout="horizontal-options" className="absolute inset-0" optionClassName="absolute inset-0" blankClassName="hidden" renderOption={(option, isUsed, isDraggingItem) => { const cfg = DRAG_ASSET_MAP[option.value as DragId] if (!cfg) return null return ( <div className={cn( cfg.className, 'w-[284px] h-[280px] bg-cover bg-center bg-no-repeat', 'flex items-center justify-center pl-2' )} style={{ backgroundImage: getBackgroundImage('zhangleping/reflection/clue/泡泡'), opacity: isUsed ? 0 : 1, }} onPointerDown={handleOptionPointerDown} > <Image src={getStaticImage(cfg.src)} /> {!isUsed && !isDraggingItem && ( <div className={cn( 'pointer-events-none absolute left-1/2 -translate-x-1/2 -bottom-4 z-10', 'bg-[#B08D6A] px-4 py-2 rounded-2xl', 'whitespace-nowrap text-2xl leading-none text-white' )} > 可拖拽 </div> )} </div> ) }} renderBlank={(blank) => ( <div data-blank-id={blank.id} className="hidden" style={{ display: 'none' }} /> )} renderDragging={(option) => { const src = DRAG_ASSET_MAP[option.value as DragId]?.src if (!src) return null return ( <img src={getStaticImage(src)} alt="" style={{ width: '10vw', height: 'auto' }} /> ) }} /> )} {/* 碎片5详情展品弹层 */} <ExhibitFlipOverlay src={getStaticImage('zhangleping/reflection/scene5/碎片5详情')} visible={showFragmentOverlay} onClose={handleCloseFragmentOverlay} /> </> )} {/* 子场景3:画板场景(进入画板模块) */} {subScene === 3 && ( <> {/* TODO: 画板模块入口 */} <div className={cn( 'absolute left-205 top-[29%] w-[284px] h-[280px] bg-cover bg-center bg-no-repeat', 'flex items-center justify-center pl-2' )} style={{ backgroundImage: getBackgroundImage('zhangleping/reflection/clue/泡泡') }} > <Image src={getStaticImage('zhangleping/reflection/clue/碎片5')} /> </div> {/* 提示 */} <SceneHint when persistent offset={{ x: 20 }}> {hasSubmittedArtwork ? '创作了三毛的你,也是三毛的好朋友啦!' : '三毛的故事,是永远的温暖。现在请你点击画板为三毛创作新时代图景吧!'} </SceneHint> {/* 未提交画作时才显示“进入画板”按钮 */} {!hasSubmittedArtwork && ( <motion.div className="absolute left-46 bottom-48 cursor-pointer" animate={haloHovered ? { scale: 1 } : { scale: [1, 0.8, 1] }} transition={ haloHovered ? { duration: 0.3, ease: 'easeOut' } : { duration: 1.5, repeat: Infinity, ease: 'easeInOut' } } onHoverStart={() => setHaloHovered(true)} onHoverEnd={() => setHaloHovered(false)} > <Image button src={getStaticImage('zhangleping/reflection/scene5/光圈')} onClick={() => navigate(`/${moduleName}/${starName}/draw`)} /> </motion.div> )} {/* 画板模块提交的画作(无画作时不显示空白画板) */} {hasSubmittedArtwork && ( <> <div className={cn( 'absolute left-40 top-178 w-[205px] h-[180px] transform-3d', 'bg-white flex justify-center items-center overflow-hidden' )} style={{ transform: 'rotateX(-15deg) rotateY(22deg) rotateZ(3deg)' }} > <Image objectFit="cover" src={drawArtworkDataUrl} /> </div> <div className="absolute left-62 top-[900px] z-20" style={{ transform: 'rotateX(-15deg) rotateY(22deg) rotateZ(3deg)' }} > <FlipActionButton className="text-2xl px-5 py-2" onClick={handleDownloadArtwork}> 下载 </FlipActionButton> </div> </> )} </> )} </div> ) } export default ZhanglepingReflectionScene5