更新依赖项及历史人物相关内容资源

- 更新 package.json 和 package-lock.json 中的依赖项
- 新增/更新陈云、黄炎培、鲁迅、孙中山的音频文件
- 新增场景和组件的图片资源
- 清理未使用的音频和图片文件
- 优化多个组件和页面的实现逻辑
This commit is contained in:
zhongyidian committed 2026-04-20 13:27:18 +08:00
1 parent b1d55b9464
commit e0fa47d21a
629 files changed
+24390 -2991

No files matched your search

+391
View File
@@ -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