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 = 'scene4' // 第一阶段:解锁相关资源(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/scene4/场景4展品1') }, 2: { src: getStaticImage('zhangleping/reflection/scene4/场景4展品2') }, } // 第二阶段:拖拽常量 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 DRAG_ITEMS = [ { id: 'drag-1', className: 'absolute left-75 bottom-85', src: 'zhangleping/reflection/scene4/拖拽1', }, { id: 'drag-2', className: 'absolute left-160 bottom-65', src: 'zhangleping/reflection/scene4/拖拽2', }, { id: 'drag-3', className: 'absolute right-76 bottom-76', src: 'zhangleping/reflection/scene4/拖拽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 ZhanglepingReflectionScene4 = () => { const navigate = useNavigate() const { moduleName } = useParams<{ moduleName: string }>() const { markReflectionCursorVisited, getVisitedReflectionCursors } = useZhanglepingStore() // 第一阶段 const [activeExhibit, setActiveExhibit] = useState(null) const [showDialogue, setShowDialogue] = useState(false) const [drawingCompleted, setDrawingCompleted] = useState(false) const visitedExhibits = getVisitedReflectionCursors(SCENE) const visited = useMemo(() => new Set(visitedExhibits), [visitedExhibits]) 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: 2, initialEnabled: true }, { id: 1, dependsOn: [2] }, ], visited, }) // 第二阶段:拖拽 const [dragFillValue, setDragFillValue] = useState([]) const isDragPhase = resourcesCompleted && !fragmentCompleted 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 getBlankFilledItem = useCallback( (blankId: DragBlankId) => { const result = dragFillValue.find((r) => r.blankId === blankId) if (!result) return null return DRAG_OPTION_ASSET_MAP[result.optionValue as DragOptionValue] ?? null }, [dragFillValue] ) 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 } // 正确:立即完成画稿,再弹出展品与对话 setDrawingCompleted(true) setDragFillValue([]) hint.clearAndReset() setTimeout(() => setShowItemExhibit(true), 300) }, [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`) 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: 140 }}> 新中国·记忆碎片已解锁 </SceneHint> )} {/* 场景提示:拖拽阶段常驻/错误提示 */} {!showItemExhibit && !showFragmentOverlay && !fragmentCompleted && isDragPhase && hint.hintMode && ( <SceneHint when triggerKey={hint.hintTriggerKey} offset={{ x: 140 }} durationMs={hint.hintMode === 'temp' ? hint.hintDurationMs : undefined} persistent={hint.hintMode === 'persistent' ? true : undefined} > {hint.hintText} </SceneHint> )} {/* 第一阶段:静态物品(不可拖拽) */} {!isDragPhase && !fragmentCompleted && DRAG_ITEMS.map((item) => ( <Image key={item.src} className={item.className} src={getStaticImage(item.src)} /> ))} {/* 画稿(未完成/完成) */} <Image className="absolute left-205 bottom-10" src={getStaticImage( `zhangleping/reflection/scene4/${drawingCompleted ? '画稿完成' : '画稿未完成'}` )} /> {/* 自定义放置区域(拖拽阶段显示) */} {isDragPhase && ( <div data-blank-id="drop-1" className={cn( 'absolute left-218 bottom-3 z-99', 'w-105 h-74', 'transform-3d rotate-x-70 -rotate-y-2 rotate-z-3 origin-top', 'flex items-center justify-center' )} > {getBlankFilledItem('drop-1') && ( <Image className="h-full object-contain" src={getStaticImage(getBlankFilledItem('drop-1')!.src)} /> )} </div> )} {/* 第二阶段: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" 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-5 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_OPTION_ASSET_MAP[option.value as DragOptionValue]?.src if (!src) return null return ( <img src={getStaticImage(src)} alt="" style={{ width: '12vw', height: 'auto' }} /> ) }} /> )} {/* 记忆碎片区域(第二阶段及之后显示) */} {(isDragPhase || fragmentCompleted) && ( <div className={cn( 'absolute left-240 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/碎片4')} /> </div> )} {/* 离开场景按钮(碎片展品关闭后出现) */} {fragmentCompleted && !showItemExhibit && !showFragmentOverlay && ( <Image className="absolute bottom-0 right-0" src={getStaticImage('components/place/离开场景')} button onClick={handleLeave} /> )} {/* 第一阶段:两个光标,先点光标2再点光标1 */} {!resourcesCompleted && ( <> <Cursor className="absolute left-103 bottom-98" size={0.6} disabled={!isEnabled(1)} read={visited.has(1)} onClick={() => openExhibit(1)} /> <Cursor className="absolute left-270 bottom-35" size={0.6} disabled={!isEnabled(2)} read={visited.has(2)} onClick={() => openExhibit(2)} /> </> )} {/* 第一阶段:展品弹层 */} {activeExhibit && ( <ExhibitFlipOverlay src={exhibitAssets[activeExhibit].src} visible onEnterComplete={() => { if (activeExhibit === 2) { setShowDialogue(true) } }} onClose={closeExhibit} /> )} {/* 第一阶段:光标2的展品对话 */} {activeExhibit === 2 && ( <SanmaoDialogueSteps showMask={false} visible={showDialogue} onVisibleChange={setShowDialogue} audioChannelId="zhangleping-exhibit-s4-2" zIndex={10000} > <SanmaoDialogueItem audioSrc={getPublicPath('audio/zhangleping/mp3_zhangleping_28.mp3')}> 1949年以后,我有了新衣服,可以上学了!爷爷说,现在的小朋友不用流浪啦!所以爷爷又创作出了爱科学的我,并在新中国第一份综合性少儿期刊《儿童时代》上连载。 </SanmaoDialogueItem> </SanmaoDialogueSteps> )} {/* 第二阶段:拖拽成功后的展品弹层(场景4展品3) */} <ExhibitFlipOverlay src={getStaticImage('zhangleping/reflection/scene4/场景4展品3')} visible={showItemExhibit} onEnterComplete={() => setShowItemDialogue(true)} onClose={handleCloseItemExhibit} /> {/* 第二阶段:展品对话 */} <SanmaoDialogueSteps showMask={false} visible={showItemDialogue} onVisibleChange={setShowItemDialogue} audioChannelId="zhangleping-exhibit-s4-drag" zIndex={10000} > <SanmaoDialogueItem audioSrc={getPublicPath('audio/zhangleping/mp3_zhangleping_29.mp3')}> 生活在新中国,我要做个爱科学的三毛。 </SanmaoDialogueItem> </SanmaoDialogueSteps> {/* 第二阶段:碎片展品(碎片4详情) */} <ExhibitFlipOverlay src={getStaticImage('zhangleping/reflection/scene4/碎片4详情')} visible={showFragmentOverlay} onClose={handleCloseFragmentOverlay} /> </div> ) } export default ZhanglepingReflectionScene4