Commit non-media application and asset changes while leaving audio, video, and environment files out of version control. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
434 lines
15 KiB
TypeScript
434 lines
15 KiB
TypeScript
import { useCallback, useMemo, useState } from 'react'
|
|
import { useNavigate, useParams } from 'react-router-dom'
|
|
import { motion } from 'framer-motion'
|
|
import {
|
|
Image,
|
|
Title,
|
|
SceneHint,
|
|
DragFill,
|
|
ExhibitFlipOverlay,
|
|
FlipActionButton,
|
|
} from '@/components'
|
|
import type { DragFillResult } from '@/components'
|
|
import { cn, getBackgroundImage, getStaticImage } from '@/lib/utils'
|
|
import { downloadDataUrlImage } from '@/lib/utils/sensitiveWords'
|
|
import { useZhanglepingStore, saveZhanglepingStore } from '@/store/zhanglepingStore'
|
|
import { useSceneHintController } from './useSceneHintController'
|
|
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
|
|
|
const starName = 'zhangleping'
|
|
|
|
// 放置碎片(释放区域)
|
|
const PLACE_ITEMS = [
|
|
{ id: 'clue-1', src: 'zhangleping/reflection/clue/内嵌基底1' },
|
|
{ id: 'clue-2', src: 'zhangleping/reflection/clue/内嵌基底2' },
|
|
{ id: 'clue-3', src: 'zhangleping/reflection/clue/内嵌基底3' },
|
|
{ id: 'clue-4', src: 'zhangleping/reflection/clue/内嵌基底4' },
|
|
] as const
|
|
|
|
// 拖拽碎片
|
|
const DRAG_ITEMS = [
|
|
{
|
|
id: 'clue-1',
|
|
className: 'absolute right-140 top-130',
|
|
src: 'zhangleping/reflection/clue/碎片1',
|
|
},
|
|
{
|
|
id: 'clue-2',
|
|
className: 'absolute right-62 bottom-25',
|
|
src: 'zhangleping/reflection/clue/碎片2',
|
|
},
|
|
{
|
|
id: 'clue-3',
|
|
className: 'absolute right-25 top-100',
|
|
src: 'zhangleping/reflection/clue/碎片3',
|
|
},
|
|
{
|
|
id: 'clue-4',
|
|
className: 'absolute right-100 top-60',
|
|
src: 'zhangleping/reflection/clue/碎片4',
|
|
},
|
|
] as const
|
|
|
|
type DragId = (typeof DRAG_ITEMS)[number]['id']
|
|
|
|
const DRAG_ASSET_MAP: Record<DragId, { className: string; src: string }> = DRAG_ITEMS.reduce(
|
|
(acc, item) => {
|
|
acc[item.id] = { className: item.className, src: item.src }
|
|
return acc
|
|
},
|
|
{} as Record<DragId, { className: string; src: string }>
|
|
)
|
|
|
|
const DRAG_OPTIONS = DRAG_ITEMS.map((i) => ({ value: i.id, label: i.id }))
|
|
const DRAG_BLANKS = PLACE_ITEMS.map((i) => ({ id: i.id, label: '', required: true }))
|
|
|
|
const DRAG_ERROR_HINT = '碎片与基底不匹配,请放到正确的位置!'
|
|
const PERSISTENT_DRAG_HINT = '将右侧记忆碎片拖拽到左侧对应的基底上,拼合完整记忆。'
|
|
const DRAG_ERROR_HINT_DURATION_MS = 3200
|
|
const DRAG_ERROR_HINT_RESET_DELAY_MS = 3300
|
|
|
|
const ZhanglepingReflectionScene5 = () => {
|
|
const navigate = useNavigate()
|
|
const { moduleName } = useParams<{ moduleName: string }>()
|
|
const [haloHovered, setHaloHovered] = useState(false)
|
|
const scene = 'scene5'
|
|
const total = 1
|
|
const {
|
|
markReflectionCursorVisited,
|
|
getVisitedReflectionCursors,
|
|
drawArtworkDataUrl,
|
|
hasDrawArtwork,
|
|
} = useZhanglepingStore()
|
|
|
|
const { triggerPuzzleAnimation } = usePuzzleAnimation()
|
|
|
|
// 根据 store 中碎片是否已解锁决定初始子场景
|
|
const fragmentAlreadyUnlocked = getVisitedReflectionCursors(scene).includes(1)
|
|
const [subScene, setSubScene] = useState<1 | 2 | 3>(fragmentAlreadyUnlocked ? 3 : 1)
|
|
|
|
const progressCount = useMemo(
|
|
() => getVisitedReflectionCursors(scene).length,
|
|
[getVisitedReflectionCursors]
|
|
)
|
|
const completed = progressCount >= total
|
|
const subtitle = completed ? '已完成' : `${Math.min(progressCount, total)}/${total}`
|
|
const hasSubmittedArtwork =
|
|
hasDrawArtwork ||
|
|
(typeof drawArtworkDataUrl === 'string' &&
|
|
/^data:image\/[a-zA-Z]+;base64,/.test(drawArtworkDataUrl))
|
|
|
|
// 子场景2:拖拽状态
|
|
const [dragFillValue, setDragFillValue] = useState<DragFillResult[]>([])
|
|
const [showFragmentOverlay, setShowFragmentOverlay] = useState(false)
|
|
const [allPlaced, setAllPlaced] = useState(false)
|
|
const [isDragging, setIsDragging] = useState(false)
|
|
const handleOptionPointerDown = useCallback(() => {
|
|
setIsDragging(true)
|
|
const handleUp = () => {
|
|
setIsDragging(false)
|
|
document.removeEventListener('pointerup', handleUp)
|
|
document.removeEventListener('pointercancel', handleUp)
|
|
}
|
|
document.addEventListener('pointerup', handleUp)
|
|
document.addEventListener('pointercancel', handleUp)
|
|
}, [])
|
|
|
|
const isDragActive = subScene === 2 && !allPlaced
|
|
const hint = useSceneHintController(isDragActive, PERSISTENT_DRAG_HINT)
|
|
|
|
const getBlankFilledSrc = useCallback(
|
|
(blankId: string) => {
|
|
const result = dragFillValue.find((r) => r.blankId === blankId)
|
|
if (!result) return null
|
|
return DRAG_ASSET_MAP[result.optionValue as DragId]?.src ?? null
|
|
},
|
|
[dragFillValue]
|
|
)
|
|
|
|
const handleDragValueChange = useCallback(
|
|
(newValue: DragFillResult[]) => {
|
|
if (!isDragActive) return
|
|
|
|
const prev = dragFillValue
|
|
const changed = newValue.filter(
|
|
(nv) => !prev.some((pv) => pv.optionValue === nv.optionValue && pv.blankId === nv.blankId)
|
|
)
|
|
|
|
// 检查新放入的是否匹配(id 必须一致)
|
|
for (const entry of changed) {
|
|
if (entry.optionValue !== entry.blankId) {
|
|
// 不匹配:只保留之前正确的
|
|
setDragFillValue(prev)
|
|
hint.showTempHint(
|
|
DRAG_ERROR_HINT,
|
|
DRAG_ERROR_HINT_DURATION_MS,
|
|
DRAG_ERROR_HINT_RESET_DELAY_MS
|
|
)
|
|
return
|
|
}
|
|
}
|
|
|
|
// 只保留匹配的
|
|
const filtered = newValue.filter((v) => v.optionValue === v.blankId)
|
|
setDragFillValue(filtered)
|
|
|
|
// 全部放好
|
|
if (filtered.length === PLACE_ITEMS.length) {
|
|
hint.clearAndReset()
|
|
markReflectionCursorVisited(scene, 1)
|
|
saveZhanglepingStore()
|
|
setShowFragmentOverlay(true)
|
|
}
|
|
},
|
|
[dragFillValue, hint, isDragActive, markReflectionCursorVisited]
|
|
)
|
|
|
|
const handleCloseFragmentOverlay = useCallback(() => {
|
|
setShowFragmentOverlay(false)
|
|
setAllPlaced(true)
|
|
triggerPuzzleAnimation(14)
|
|
// 短暂展示插入碎片后效果,然后进入画板场景
|
|
setTimeout(() => setSubScene(3), 800)
|
|
}, [triggerPuzzleAnimation])
|
|
|
|
const handleDownloadArtwork = useCallback(async () => {
|
|
if (!hasSubmittedArtwork) return
|
|
|
|
try {
|
|
await downloadDataUrlImage(drawArtworkDataUrl, '三毛新时代图景.png')
|
|
} catch (error) {
|
|
console.error('Download artwork failed', error)
|
|
}
|
|
}, [drawArtworkDataUrl, hasSubmittedArtwork])
|
|
|
|
if (!moduleName) return null
|
|
|
|
const subSceneBg = { 1: '场景5', 2: '邮箱', 3: '画板' }[subScene]
|
|
|
|
return (
|
|
<div
|
|
className="w-full h-full bg-cover bg-center bg-no-repeat relative perspective-midrange"
|
|
style={{
|
|
backgroundImage: getBackgroundImage(`${starName}/reflection/scene5/${subSceneBg}`),
|
|
}}
|
|
>
|
|
<Title
|
|
title="创作心路"
|
|
subtitle={subtitle}
|
|
className="absolute top-10 left-1/2 -translate-x-1/2 z-10"
|
|
subtitleClassName={completed ? 'text-[#fff998]' : undefined}
|
|
/>
|
|
|
|
{!showFragmentOverlay && (
|
|
<Image
|
|
className="absolute right-5 top-5 z-10"
|
|
src={getStaticImage('components/返回')}
|
|
button
|
|
onClick={() =>
|
|
navigate(`/${moduleName}/${starName}/reflection`, {
|
|
state: { fromReflectionSubpage: true },
|
|
})
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{/* 子场景1:找寻碎片,点击光圈进入邮箱 */}
|
|
{subScene === 1 && (
|
|
<>
|
|
<SceneHint when persistent>
|
|
点击画面,寻找最终的碎片
|
|
</SceneHint>
|
|
|
|
<motion.div
|
|
className="absolute left-178 bottom-10 cursor-pointer"
|
|
animate={haloHovered ? { scale: 1 } : { scale: [1, 0.8, 1] }}
|
|
transition={
|
|
haloHovered
|
|
? { duration: 0.3, ease: 'easeOut' }
|
|
: { duration: 1.5, repeat: Infinity, ease: 'easeInOut' }
|
|
}
|
|
onHoverStart={() => setHaloHovered(true)}
|
|
onHoverEnd={() => setHaloHovered(false)}
|
|
>
|
|
<Image
|
|
button
|
|
src={getStaticImage('zhangleping/reflection/scene5/光圈')}
|
|
onClick={() => setSubScene(2)}
|
|
/>
|
|
</motion.div>
|
|
</>
|
|
)}
|
|
|
|
{/* 子场景2:邮箱场景(拖拽碎片任务) */}
|
|
{subScene === 2 && (
|
|
<>
|
|
{/* 拖拽提示 */}
|
|
{!showFragmentOverlay && !allPlaced && !isDragging && 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>
|
|
)}
|
|
|
|
{/* 插入碎片前/后 */}
|
|
<div className="absolute left-100 top-32">
|
|
<Image
|
|
src={getStaticImage(
|
|
`zhangleping/reflection/scene5/${allPlaced ? '插入碎片后' : '插入碎片前'}`
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
{/* 放置碎片区域(自定义释放区域) */}
|
|
<div className="absolute left-70 top-105 w-131 h-105 pl-10 pt-10">
|
|
<div className="relative w-full h-full grid grid-cols-2 gap-x-10">
|
|
{PLACE_ITEMS.map((item) => {
|
|
const filledSrc = getBlankFilledSrc(item.id)
|
|
return (
|
|
<div
|
|
key={item.id}
|
|
data-blank-id={item.id}
|
|
className={cn(
|
|
'relative flex items-center justify-center transition-all',
|
|
filledSrc ? 'opacity-100' : 'opacity-80'
|
|
)}
|
|
>
|
|
<Image objectFit="contain" src={getStaticImage(item.src)} />
|
|
{filledSrc && (
|
|
<Image
|
|
className="absolute inset-0 w-full h-full"
|
|
objectFit="contain"
|
|
src={getStaticImage(filledSrc)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* DragFill 拖拽交互 */}
|
|
{!allPlaced && (
|
|
<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, isDraggingItem) => {
|
|
const cfg = DRAG_ASSET_MAP[option.value as DragId]
|
|
if (!cfg) return null
|
|
return (
|
|
<div
|
|
className={cn(
|
|
cfg.className,
|
|
'w-[284px] h-[280px] bg-cover bg-center bg-no-repeat',
|
|
'flex items-center justify-center pl-2'
|
|
)}
|
|
style={{
|
|
backgroundImage: getBackgroundImage('zhangleping/reflection/clue/泡泡'),
|
|
opacity: isUsed ? 0 : 1,
|
|
}}
|
|
onPointerDown={handleOptionPointerDown}
|
|
>
|
|
<Image src={getStaticImage(cfg.src)} />
|
|
{!isUsed && !isDraggingItem && (
|
|
<div
|
|
className={cn(
|
|
'pointer-events-none absolute left-1/2 -translate-x-1/2 -bottom-4 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_ASSET_MAP[option.value as DragId]?.src
|
|
if (!src) return null
|
|
return (
|
|
<img src={getStaticImage(src)} alt="" style={{ width: '10vw', height: 'auto' }} />
|
|
)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* 碎片5详情展品弹层 */}
|
|
<ExhibitFlipOverlay
|
|
src={getStaticImage('zhangleping/reflection/scene5/碎片5详情')}
|
|
visible={showFragmentOverlay}
|
|
onClose={handleCloseFragmentOverlay}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{/* 子场景3:画板场景(进入画板模块) */}
|
|
{subScene === 3 && (
|
|
<>
|
|
{/* TODO: 画板模块入口 */}
|
|
<div
|
|
className={cn(
|
|
'absolute left-205 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/碎片5')} />
|
|
</div>
|
|
|
|
{/* 提示 */}
|
|
<SceneHint when persistent offset={{ x: 20 }}>
|
|
{hasSubmittedArtwork
|
|
? '创作了三毛的你,也是三毛的好朋友啦!'
|
|
: '三毛的故事,是永远的温暖。现在请你点击画板为三毛创作新时代图景吧!'}
|
|
</SceneHint>
|
|
|
|
{/* 未提交画作时才显示“进入画板”按钮 */}
|
|
{!hasSubmittedArtwork && (
|
|
<motion.div
|
|
className="absolute left-46 bottom-48 cursor-pointer"
|
|
animate={haloHovered ? { scale: 1 } : { scale: [1, 0.8, 1] }}
|
|
transition={
|
|
haloHovered
|
|
? { duration: 0.3, ease: 'easeOut' }
|
|
: { duration: 1.5, repeat: Infinity, ease: 'easeInOut' }
|
|
}
|
|
onHoverStart={() => setHaloHovered(true)}
|
|
onHoverEnd={() => setHaloHovered(false)}
|
|
>
|
|
<Image
|
|
button
|
|
src={getStaticImage('zhangleping/reflection/scene5/光圈')}
|
|
onClick={() => navigate(`/${moduleName}/${starName}/draw`)}
|
|
/>
|
|
</motion.div>
|
|
)}
|
|
|
|
{/* 画板模块提交的画作(无画作时不显示空白画板) */}
|
|
{hasSubmittedArtwork && (
|
|
<>
|
|
<div
|
|
className={cn(
|
|
'absolute left-40 top-178 w-[205px] h-[180px] transform-3d',
|
|
'bg-white flex justify-center items-center overflow-hidden'
|
|
)}
|
|
style={{ transform: 'rotateX(-15deg) rotateY(22deg) rotateZ(3deg)' }}
|
|
>
|
|
<Image objectFit="cover" src={drawArtworkDataUrl} />
|
|
</div>
|
|
|
|
<div
|
|
className="absolute left-62 top-[900px] z-20"
|
|
style={{ transform: 'rotateX(-15deg) rotateY(22deg) rotateZ(3deg)' }}
|
|
>
|
|
<FlipActionButton className="text-2xl px-5 py-2" onClick={handleDownloadArtwork}>
|
|
下载
|
|
</FlipActionButton>
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default ZhanglepingReflectionScene5
|