Files
shanghai-residences/src/pages/zhangleping/reflection/scene1.tsx
T
2026-04-30 09:17:31 +08:00

403 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 = 'scene1'
const EMPTY_SET = new Set<number>()
// 第一段:解锁相关资源(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<ExhibitId, { src: string; flipSrc?: string }> = {
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<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 = 'drop-1'
const DRAG_BLANKS = [{ id: 'drop-1' as DragBlankId, label: '', required: true }]
const DRAG_BLANK_POSITION: Record<DragBlankId, string> = {
'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<ExhibitId | null>(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<DragFillResult[]>([])
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 (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat relative"
style={{
backgroundImage: getBackgroundImage(
`${STAR_NAME}/reflection/scene1/${isDragPhase ? '场景-拖拽' : '场景-进入'}`
),
}}
>
<Title
title="童年学艺"
subtitle={subtitle}
className="absolute top-10 left-1/2 -translate-x-1/2"
subtitleClassName={completed ? 'text-[#fff998]' : undefined}
/>
{!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