更新依赖项及历史人物相关内容资源
- 更新 package.json 和 package-lock.json 中的依赖项 - 新增/更新陈云、黄炎培、鲁迅、孙中山的音频文件 - 新增场景和组件的图片资源 - 清理未使用的音频和图片文件 - 优化多个组件和页面的实现逻辑
This commit is contained in:
1 parent
b1d55b9464
commit
e0fa47d21a
629 files changed
+24390
-2991
No files matched your search
@@ -0,0 +1,391 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Image, Title, SceneHint, DragFill, ExhibitFlipOverlay } from '@/components'
|
||||
import type { DragFillResult } from '@/components'
|
||||
import { cn, getBackgroundImage, getStaticImage } from '@/lib/utils'
|
||||
import { useZhanglepingStore, saveZhanglepingStore } from '@/store/zhanglepingStore'
|
||||
import { useSceneHintController } from './useSceneHintController'
|
||||
|
||||
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 } =
|
||||
useZhanglepingStore()
|
||||
|
||||
// 根据 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 =
|
||||
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)
|
||||
// 短暂展示插入碎片后效果,然后进入画板场景
|
||||
setTimeout(() => setSubScene(3), 800)
|
||||
}, [])
|
||||
|
||||
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`)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 子场景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>
|
||||
)
|
||||
}
|
||||
|
||||
export default ZhanglepingReflectionScene5
|
||||
Reference in new issue
Block a user