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 = 'scene3' const EMPTY_SET = new Set() // 第一阶段:解锁相关资源(2 个) const RESOURCE_TOTAL = 2 // 第二阶段:解锁记忆碎片(1 个) const FRAGMENT_TOTAL = 1 type ExhibitId = 1 | 2 type FragmentId = 3 const exhibitAssets: Record = { 1: { src: getStaticImage('zhangleping/reflection/scene3/场景3展品2') }, 2: { src: getStaticImage('zhangleping/reflection/scene3/场景3展品1') }, } const exhibitDialogues: Record> = { 1: { text: '1946年,米价涨了300倍,但我还是想把最后半块饼分给更饿的人。', audioSrc: getPublicPath('audio/zhangleping/mp3_zhangleping_26.mp3'), }, 2: { text: '那时候我没家,但总想帮别人......爷爷说我心里还装着温暖。', audioSrc: getPublicPath('audio/zhangleping/mp3_zhangleping_25.mp3'), }, } // 第二阶段:拖拽常量 const CORRECT_DRAG_OPTION = 'drag-3' const DRAG_ERROR_HINT = '这个物品虽然与三毛有关,但并不是解锁记忆碎片的关键。试试找找与“苦难里还有善良”相关的物品吧!' const PERSISTENT_DRAG_HINT = '拖拽正确的物品到这里解锁记忆碎片' const DRAG_ERROR_HINT_DURATION_MS = 3200 const DRAG_ERROR_HINT_RESET_DELAY_MS = 3300 const DRAG_ITEMS = [ { id: 'drag-1', className: 'absolute left-[3%] top-[6%]', src: 'zhangleping/reflection/scene3/点击项1', }, { id: 'drag-2', className: 'absolute left-[36.3%] bottom-[15%]', src: 'zhangleping/reflection/scene3/点击项2', }, { id: 'drag-3', className: 'absolute left-180 bottom-10', src: 'zhangleping/reflection/scene3/点击项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 = 'fragment-drop' const DRAG_BLANKS = [{ id: 'fragment-drop' as DragBlankId, label: '', required: true }] const ZhanglepingReflectionScene3 = () => { 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 [showDialogue, setShowDialogue] = 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, dependsOn: [2] }, { id: 2, initialEnabled: true }, ], visited, }) // 第二阶段:拖拽物品 const isDragPhase = resourcesCompleted && !fragmentCompleted const [dragFillValue, setDragFillValue] = useState([]) const [showItemExhibit, setShowItemExhibit] = useState(false) const [showItemDialogue, setShowItemDialogue] = useState(false) const [showFragmentOverlay, setShowFragmentOverlay] = 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 === 'fragment-drop') 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() setShowItemExhibit(true) }, [hint, isDragPhase] ) const handleCloseItemExhibit = useCallback(() => { setShowItemExhibit(false) setShowItemDialogue(false) setTimeout(() => setShowFragmentOverlay(true), 500) }, []) const handleCloseFragmentOverlay = useCallback(() => { setShowFragmentOverlay(false) markVisitedAndSave(3 as FragmentId) setSuccessHintKey((k) => k + 1) }, [markVisitedAndSave]) const handleLeave = () => navigate(`/${moduleName}/${STAR_NAME}/reflection`, { state: { fromReflectionSubpage: true } }) const openExhibit = (id: ExhibitId) => { setActiveExhibit(id) setShowDialogue(false) } const closeExhibit = () => { if (activeExhibit !== null) { markVisitedAndSave(activeExhibit) } setActiveExhibit(null) setShowDialogue(false) } if (!moduleName) return null return (
{!showItemExhibit && !showFragmentOverlay && ( <Image className="absolute right-5 top-5 z-10" src={getStaticImage('components/返回')} button onClick={handleLeave} /> )} {/* 场景提示:碎片已解锁 */} {!showItemExhibit && !showFragmentOverlay && fragmentCompleted && ( <SceneHint when triggerKey={successHintKey} persistent offset={{ x: -20 }}> 苦难善良·记忆碎片已解锁 </SceneHint> )} {/* 场景提示:拖拽阶段常驻/错误提示 */} {!showItemExhibit && !showFragmentOverlay && !fragmentCompleted && isDragPhase && 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> )} {/* 静态物品(第一阶段纯展示,第二阶段可拖拽) */} {!isDragPhase && DRAG_ITEMS.map((item) => ( <Image key={item.src} className={item.className} src={getStaticImage(item.src)} /> ))} {/* 第二阶段:拖拽物品到记忆碎片区域 */} {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" blankClassName="hidden" 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) => ( <div data-blank-id={blank.id} className={cn( 'absolute left-[42.8%] top-[29%] w-[284px] h-[280px] bg-cover bg-center bg-no-repeat', 'flex items-center justify-center pl-2 transition-all duration-150', isHovered && 'scale-105 drop-shadow-[0_0_20px_rgba(255,249,152,0.95)]' )} style={{ backgroundImage: getBackgroundImage('zhangleping/reflection/clue/泡泡') }} > <Image style={{ filter: isHovered ? 'grayscale(100%) brightness(0) drop-shadow(0 0 12px rgba(255, 249, 152, 0.9))' : 'grayscale(100%) brightness(0)', opacity: isHovered ? 0.9 : 0.7, }} src={getStaticImage('zhangleping/reflection/clue/碎片3')} /> </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: '8vw', height: 'auto' }} /> }} /> )} {/* 第一阶段:两个光标,按顺序解锁 */} {!resourcesCompleted && ( <> <Cursor className="absolute left-198 bottom-50" size={0.6} disabled={!isEnabled(1)} read={visited.has(1)} onClick={() => openExhibit(1)} /> <Cursor className="absolute left-254 bottom-68" size={0.6} disabled={!isEnabled(2)} read={visited.has(2)} onClick={() => openExhibit(2)} /> </> )} {/* 记忆碎片区域(解锁后显示) */} {fragmentCompleted && ( <div className={cn( 'absolute left-[42.8%] 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/碎片3')} /> </div> )} {/* 离开场景按钮(碎片展品关闭后出现) */} {fragmentCompleted && !showItemExhibit && !showFragmentOverlay && ( <Image className="absolute bottom-0 right-0" src={getStaticImage('components/place/离开场景')} button onClick={handleLeave} /> )} {/* 第一阶段:展品弹层 */} {activeExhibit && ( <ExhibitFlipOverlay src={exhibitAssets[activeExhibit].src} visible onEnterComplete={() => setShowDialogue(true)} onClose={closeExhibit} /> )} {/* 第一阶段:展品对话 */} {activeExhibit && ( <SanmaoDialogueSteps showMask={false} visible={showDialogue} onVisibleChange={setShowDialogue} audioChannelId={`zhangleping-exhibit-s3-${activeExhibit}`} zIndex={10000} > <SanmaoDialogueItem audioSrc={exhibitDialogues[activeExhibit].audioSrc}> {exhibitDialogues[activeExhibit].text} </SanmaoDialogueItem> </SanmaoDialogueSteps> )} {/* 第二阶段:拖拽半块饼后的展品弹层(场景3展品3) */} <ExhibitFlipOverlay src={getStaticImage('zhangleping/reflection/scene3/场景3展品3')} visible={showItemExhibit} onEnterComplete={() => setShowItemDialogue(true)} onClose={handleCloseItemExhibit} /> {/* 第二阶段:展品对话 */} <SanmaoDialogueSteps showMask={false} visible={showItemDialogue} onVisibleChange={setShowItemDialogue} audioChannelId="zhangleping-exhibit-s3-item2" zIndex={10000} > <SanmaoDialogueItem audioSrc={getPublicPath('audio/zhangleping/mp3_zhangleping_27.mp3')}> 流浪的三毛,从来没忘记帮助别人,苦难里还有善良! </SanmaoDialogueItem> </SanmaoDialogueSteps> {/* 第二阶段:碎片展品(碎片3详情) */} <ExhibitFlipOverlay src={getStaticImage('zhangleping/reflection/scene3/碎片3详情')} visible={showFragmentOverlay} onClose={handleCloseFragmentOverlay} /> </div> ) } export default ZhanglepingReflectionScene3