import { useCallback, useMemo, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { Image, Title, Cursor, ExhibitFlipOverlay, DragFill, SceneHint } from '@/components' import type { DragFillResult } from '@/components' import { cn, getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils' import { useZhanglepingStore, saveZhanglepingStore } from '@/store/zhanglepingStore' import { SanmaoDialogueItem, SanmaoDialogueSteps } from '@/pages/zhangleping/components' import { useCursorController } from '@/hooks' import { useSceneHintController } from './useSceneHintController' const STAR_NAME = 'zhangleping' const SCENE = 'scene1' const EMPTY_SET = new Set() // 第一段:解锁相关资源(2 个) const RESOURCE_TOTAL = 2 // 第二段:解锁记忆碎片(1 个) const FRAGMENT_TOTAL = 1 type ExhibitId = 1 | 2 type FragmentId = 3 // 第二阶段:唯一正确物品(其余都算“选择题错误”) const CORRECT_DRAG_OPTION = 'drag-1' const DRAG_ERROR_HINT = '这些物品虽然与绘画有关,但并不是解锁记忆碎片的关键。试试找找与“眼里看到的真东西”相关的物品吧!' const PERSISTENT_DRAG_HINT = '拖拽关联的物品至虚框处解锁记忆碎片' const DRAG_ERROR_HINT_DURATION_MS = 3200 const DRAG_ERROR_HINT_RESET_DELAY_MS = 3300 const exhibitAssets: Record = { 1: { src: getStaticImage('zhangleping/reflection/scene1/场景1展品1'), }, 2: { src: getStaticImage('zhangleping/reflection/scene1/场景1展品2正面'), flipSrc: getStaticImage('zhangleping/reflection/scene1/场景1展品2反面'), }, } const DRAG_ITEMS = [ { id: 'drag-1', className: 'absolute left-[21%] top-[66.5%]', src: 'zhangleping/reflection/scene1/场景1拖拽1', }, { id: 'drag-2', className: 'absolute right-[28%] bottom-[26%]', src: 'zhangleping/reflection/scene1/场景1拖拽2', }, { id: 'drag-3', className: 'absolute right-[22%] bottom-[14%]', src: 'zhangleping/reflection/scene1/场景1拖拽3', }, ] as const type DragOptionValue = (typeof DRAG_ITEMS)[number]['id'] const DRAG_OPTION_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 })) type DragBlankId = 'drop-1' const DRAG_BLANKS = [{ id: 'drop-1' as DragBlankId, label: '', required: true }] const DRAG_BLANK_POSITION: Record = { 'drop-1': 'absolute left-[41.3%] top-[67%] w-[12%] h-[26%] perspective-1000', } const ZhanglepingReflectionScene1 = () => { const navigate = useNavigate() const { moduleName } = useParams<{ moduleName: string }>() const { markReflectionCursorVisited } = useZhanglepingStore() const visited = useZhanglepingStore((state) => state.visitedReflectionCursors[SCENE] ?? EMPTY_SET) const [activeExhibit, setActiveExhibit] = useState(null) const [showExhibit1Dialogue, setShowExhibit1Dialogue] = useState(false) const markVisitedAndSave = useCallback( (id: ExhibitId | FragmentId) => { markReflectionCursorVisited(SCENE, id) saveZhanglepingStore() }, [markReflectionCursorVisited] ) const resourceProgress = useMemo( () => Array.from(visited).filter((id) => id === 1 || id === 2).length, [visited] ) const resourcesCompleted = resourceProgress >= RESOURCE_TOTAL const fragmentCompleted = useMemo(() => visited.has(3), [visited]) const fragmentProgress = fragmentCompleted ? 1 : 0 const completed = fragmentCompleted const subtitle = resourcesCompleted ? `${fragmentProgress}/${FRAGMENT_TOTAL}` : `${Math.min(resourceProgress, RESOURCE_TOTAL)}/${RESOURCE_TOTAL}` const { isEnabled } = useCursorController({ unlockRules: [ { id: 1, initialEnabled: true }, { id: 2, dependsOn: [1] }, ], visited, }) // 第二阶段:拖拽填空 const [dragFillValue, setDragFillValue] = useState([]) const isDragPhase = resourcesCompleted && !fragmentCompleted const [showFragmentOverlay, setShowFragmentOverlay] = useState(false) const [showFragmentDialogue, setShowFragmentDialogue] = useState(false) const [successHintKey, setSuccessHintKey] = useState(0) const hint = useSceneHintController(isDragPhase, PERSISTENT_DRAG_HINT) const handleDragValueChange = useCallback( (newValue: DragFillResult[]) => { setDragFillValue(newValue) if (!isDragPhase) return const answer = newValue.find((v) => v.blankId === 'drop-1') if (!answer) return if (answer.optionValue !== CORRECT_DRAG_OPTION) { // 放错:退回并提示 setDragFillValue([]) hint.showTempHint( DRAG_ERROR_HINT, DRAG_ERROR_HINT_DURATION_MS, DRAG_ERROR_HINT_RESET_DELAY_MS ) return } // 正确:弹出展品与对话(碎片在关闭碎片展品窗口时才解锁) hint.clearAndReset() setShowFragmentOverlay(true) // setShowFragmentDialogue(true) }, [hint, isDragPhase] ) const handleLeave = () => navigate(`/${moduleName}/${STAR_NAME}/reflection`) const openExhibit = (id: ExhibitId) => { setActiveExhibit(id) setShowExhibit1Dialogue(false) } const closeExhibit = () => { if (activeExhibit !== null) { markVisitedAndSave(activeExhibit) } setActiveExhibit(null) setShowExhibit1Dialogue(false) } if (!moduleName) return null return (
{!showFragmentOverlay && ( <Image className="absolute right-5 top-5 z-10" src={getStaticImage('components/返回')} button onClick={handleLeave} /> )} {/* 场景提示:拖拽阶段提示 / 解锁成功提示 */} {!showFragmentOverlay && fragmentCompleted && ( <SceneHint when triggerKey={successHintKey} persistent offset={{ x: -60 }}> 童年学艺·记忆碎片已解锁 </SceneHint> )} {!showFragmentOverlay && !fragmentCompleted && isDragPhase && hint.hintMode && ( <SceneHint when triggerKey={hint.hintTriggerKey} offset={{ x: -60 }} durationMs={hint.hintMode === 'temp' ? hint.hintDurationMs : undefined} persistent={hint.hintMode === 'persistent' ? true : undefined} > {hint.hintText} </SceneHint> )} {/* 第一阶段:展示静态拖拽物品(不可拖拽) */} {!isDragPhase && DRAG_ITEMS.map((item) => ( <Image key={item.src} className={item.className} src={getStaticImage(item.src)} /> ))} {/* 第二阶段:拖拽 3 个物品到指定区域(DragFill 实现) */} {isDragPhase && ( <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" renderOption={(option, isUsed, isDragging) => { const cfg = DRAG_OPTION_ASSET_MAP[option.value as DragOptionValue] if (!cfg) return null return ( <div className={`${cfg.className}`} style={{ opacity: isUsed ? 0 : 1 }}> <Image src={getStaticImage(cfg.src)} /> {!isUsed && !isDragging && ( <div className={cn( 'pointer-events-none absolute left-1/2 -translate-x-1/2 -bottom-10 z-10', 'bg-[#B08D6A] px-4 py-2 rounded-2xl', 'whitespace-nowrap text-2xl leading-none text-white' )} > 可拖拽 </div> )} </div> ) }} renderBlank={(blank, filledValue, isHovered) => { const filledSrc = filledValue ? DRAG_OPTION_ASSET_MAP[filledValue as DragOptionValue]?.src : undefined const pos = DRAG_BLANK_POSITION[blank.id as (typeof DRAG_BLANKS)[number]['id']] ?? 'absolute left-1/2 top-1/2 w-[10%] h-[10%]' return ( <div className={pos} style={{ perspective: 1000, WebkitPerspective: '1000px' }}> <div className="w-full h-full transform-3d rotate-x-70 rotate-y-0 rotate-z-0 origin-top" data-blank-id={blank.id} style={{ backgroundColor: isHovered ? 'rgba(191, 153, 115, 0.3)' : 'transparent', borderRadius: 12, WebkitTransformStyle: 'preserve-3d', }} > {filledSrc ? ( <div className="w-full h-full"> <Image className="w-full h-full" src={getStaticImage(filledSrc)} /> </div> ) : null} </div> </div> ) }} renderDragging={(option) => { const src = DRAG_OPTION_ASSET_MAP[option.value as DragOptionValue]?.src if (!src) return null return ( <img src={getStaticImage(src)} alt="" style={{ width: '12vw', height: 'auto' }} /> ) }} /> )} {/* 离开场景返回导览页(碎片展品关闭后出现) */} {fragmentCompleted && !showFragmentOverlay && ( <Image className="absolute bottom-0 right-0" src={getStaticImage('components/place/离开场景')} button onClick={handleLeave} /> )} {/* 记忆碎片区域 */} {(isDragPhase || fragmentCompleted) && ( <div className={cn( 'absolute left-[39.6%] 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 style={ fragmentCompleted ? undefined : { filter: 'grayscale(100%) brightness(0)', opacity: 0.7, } } src={getStaticImage('zhangleping/reflection/clue/碎片1')} /> </div> )} {/* 第一阶段:按顺序点击解锁相关资源(2/2) */} {!isDragPhase && !fragmentCompleted && ( <> <Cursor className="absolute left-[44%] top-[64%]" disabled={!isEnabled(1)} read={visited.has(1)} onClick={() => { openExhibit(1) }} /> <Cursor className="absolute right-[24%] bottom-[14%]" disabled={!isEnabled(2)} read={visited.has(2)} onClick={() => { openExhibit(2) }} /> </> )} {activeExhibit && ( <ExhibitFlipOverlay src={exhibitAssets[activeExhibit].src} flipSrc={exhibitAssets[activeExhibit].flipSrc} visible onEnterComplete={() => { if (activeExhibit === 1) { setShowExhibit1Dialogue(true) } }} onClose={closeExhibit} /> )} {/* 展品1讲解对话(等展品入场动画结束再出现) */} <SanmaoDialogueSteps showMask={false} visible={showExhibit1Dialogue} onVisibleChange={setShowExhibit1Dialogue} audioChannelId="zhangleping-exhibit-1" zIndex={10000} > <SanmaoDialogueItem audioSrc={getPublicPath('audio/zhangleping/mp3_zhangleping_21.mp3')}> 这是爷爷第一幅投稿作品!但他说,真正教会他画画的,是他母亲的绣品。 </SanmaoDialogueItem> </SanmaoDialogueSteps> {/* 记忆碎片展品弹层与三毛对话(解锁后自动出现) */} <ExhibitFlipOverlay src={getStaticImage('zhangleping/reflection/scene1/碎片1详情')} visible={showFragmentOverlay} onEnterComplete={() => { setShowFragmentDialogue(true) }} onClose={() => { setShowFragmentOverlay(false) setShowFragmentDialogue(false) markVisitedAndSave(3 as FragmentId) setSuccessHintKey((k) => k + 1) }} /> <SanmaoDialogueSteps showMask={false} visible={showFragmentDialogue} onVisibleChange={setShowFragmentDialogue} audioChannelId="zhangleping-fragment-1" zIndex={10000} > <SanmaoDialogueItem audioSrc={getPublicPath('audio/zhangleping/mp3_zhangleping_22.mp3')}> 原来爷爷用画笔记录普通人的生活!这才创造出“我” </SanmaoDialogueItem> </SanmaoDialogueSteps> </div> ) } export default ZhanglepingReflectionScene1