进度模拟面板写入 puzzleStore 后,App 启动时易班同步会将其覆盖; 现在在同步完成后按 sessionStorage 中保存的模式重放模拟进度, 确保测试包的"到达某故居/全流程完成"调试入口生效。 同时纳入近期未提交的页面、组件、资源与环境变量改动。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
402 lines
14 KiB
TypeScript
402 lines
14 KiB
TypeScript
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<number>()
|
|
|
|
// 第一阶段:解锁相关资源(2 个)
|
|
const RESOURCE_TOTAL = 2
|
|
// 第二阶段:解锁记忆碎片(1 个)
|
|
const FRAGMENT_TOTAL = 1
|
|
|
|
type ExhibitId = 1 | 2
|
|
type FragmentId = 3
|
|
|
|
const exhibitAssets: Record<ExhibitId, { src: string }> = {
|
|
1: { src: getStaticImage('zhangleping/reflection/scene3/场景3展品2') },
|
|
2: { src: getStaticImage('zhangleping/reflection/scene3/场景3展品1') },
|
|
}
|
|
|
|
const exhibitDialogues: Record<ExhibitId, Record<string, string>> = {
|
|
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<DragOptionValue, { className: string; src: string }> =
|
|
DRAG_ITEMS.reduce(
|
|
(acc, item) => {
|
|
acc[item.id] = { className: item.className, src: item.src }
|
|
return acc
|
|
},
|
|
{} as Record<DragOptionValue, { className: string; src: string }>
|
|
)
|
|
|
|
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<ExhibitId | null>(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<DragFillResult[]>([])
|
|
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 (
|
|
<div
|
|
className="w-full h-full bg-cover bg-center bg-no-repeat relative"
|
|
style={{
|
|
backgroundImage: getBackgroundImage(
|
|
`${STAR_NAME}/reflection/scene3/${isDragPhase ? '场景-点击' : '场景-进入'}`
|
|
),
|
|
}}
|
|
>
|
|
<Title
|
|
title="流浪岁月"
|
|
subtitle={subtitle}
|
|
className="absolute top-10 left-1/2 -translate-x-1/2"
|
|
subtitleClassName={completed ? 'text-[#fff998]' : undefined}
|
|
/>
|
|
|
|
{!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
|