feat: add puzzle residence configuration and reflection progress logic

- Introduced `puzzleResidences.ts` to define puzzle residence configurations including properties like display name, position, and puzzle availability.
- Added `reflectionProgress.ts` to manage badge unlocking logic based on place completion, including functions to determine unlocked badges and video sources for scenes.
This commit is contained in:
zhongyidian committed 2026-04-27 11:49:30 +08:00
1 parent e0fa47d21a
commit f5f36f75b5
153 files changed
+3846 -2352

No files matched your search

+53 -80
View File
@@ -1,11 +1,20 @@
import { Image } from '@/components'
import { cn, getBackgroundImage, getStaticImage } from '@/lib/utils'
import { Image, StudyGuideModal } from '@/components'
import { cn, getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useMemo } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { LOBBY_COLUMNS, LOBBY_LAYOUT, LOBBY_TIMELINE, PLACE_REQUIRED_COUNTS } from './lobby.config'
import { LOBBY_COLUMNS, LOBBY_LAYOUT, LOBBY_TIMELINE } from './lobby.config'
import { useNieerStore } from '@/store/nieerStore'
import { useDialogReducer } from './useDialogReducer'
import DialogLayer from './DialogLayer'
import {
BADGE_CONFIG,
getAllPlacesCompleted,
getBadgeUnlockedMap,
getGuideSceneId,
getPlaceCompletionMap,
getUnlockVideoSrcForScene,
getUnlockedByIdMap,
} from './reflectionProgress'
const starName = 'nieer'
@@ -16,61 +25,38 @@ const NieerReflection = () => {
const visitedReflectionCursors = useNieerStore((state) => state.visitedReflectionCursors)
const markReflectionCursorVisited = useNieerStore((state) => state.markReflectionCursorVisited)
const setReflectionWork = useNieerStore((state) => state.setReflectionWork)
const hasSeenReflectionGuide = useNieerStore((state) => state.hasSeenReflectionGuide)
const markReflectionGuideSeen = useNieerStore((state) => state.markReflectionGuideSeen)
const showGuideModal = !hasSeenReflectionGuide
const {
state: dialogState,
openScene,
enterUnlock,
handleShare,
finishBadgeVideo,
close,
} = useDialogReducer(markReflectionCursorVisited, setReflectionWork)
const placeCompletion = useMemo(() => {
const getVisitedCount = (placeName: string): number =>
visitedReflectionCursors[placeName]?.size ?? 0
return {
1: getVisitedCount('place1') >= (PLACE_REQUIRED_COUNTS[1] ?? 0),
2: getVisitedCount('place2') >= (PLACE_REQUIRED_COUNTS[2] ?? 0),
3: getVisitedCount('place3') >= (PLACE_REQUIRED_COUNTS[3] ?? 0),
4: getVisitedCount('place4') >= (PLACE_REQUIRED_COUNTS[4] ?? 0),
5: getVisitedCount('place5') >= (PLACE_REQUIRED_COUNTS[5] ?? 0),
6: getVisitedCount('place6') >= (PLACE_REQUIRED_COUNTS[6] ?? 0),
} as const
}, [visitedReflectionCursors])
const unlockedById = useMemo(
() =>
({
1: true,
2: placeCompletion[1],
3: placeCompletion[2],
4: placeCompletion[3],
5: placeCompletion[4],
6: placeCompletion[5],
}) as const,
[placeCompletion]
const placeCompletion = useMemo(
() => getPlaceCompletionMap(visitedReflectionCursors),
[visitedReflectionCursors]
)
const badgeUnlocked = useMemo(
() => ({
1: placeCompletion[1] && placeCompletion[2],
2: placeCompletion[3] && placeCompletion[4],
3: placeCompletion[5] && placeCompletion[6],
}),
[placeCompletion]
)
const guideSceneId = useMemo(() => {
const nextScene = LOBBY_COLUMNS.find(
(scene) => !placeCompletion[scene.id as keyof typeof placeCompletion]
)
return nextScene?.id ?? 6
}, [placeCompletion])
const unlockedById = useMemo(() => getUnlockedByIdMap(placeCompletion), [placeCompletion])
const badgeUnlocked = useMemo(() => getBadgeUnlockedMap(placeCompletion), [placeCompletion])
const guideSceneId = useMemo(() => getGuideSceneId(placeCompletion), [placeCompletion])
const allPlacesCompleted = useMemo(
() => LOBBY_COLUMNS.every((scene) => placeCompletion[scene.id as keyof typeof placeCompletion]),
() => getAllPlacesCompleted(placeCompletion),
[placeCompletion]
)
const handleResultShare = (sceneId: number, payload?: Record<string, unknown>) => {
handleShare({
payload,
unlockVideoSrc: getUnlockVideoSrcForScene(sceneId, visitedReflectionCursors),
})
}
if (!moduleName) return null
return (
@@ -78,7 +64,6 @@ const NieerReflection = () => {
className="w-full h-full relative bg-cover bg-center bg-no-repeat overflow-hidden"
style={{ backgroundImage: getBackgroundImage(`${starName}/reflection/lobby/场景首页底图`) }}
>
{/* 返回按钮 */}
<Image
className="absolute right-6 top-5 z-30"
src={getStaticImage('components/返回')}
@@ -86,7 +71,6 @@ const NieerReflection = () => {
onClick={() => navigate(`/${moduleName}/${starName}/home`)}
/>
{/* 六列时间轴内容 */}
<div
className={cn(
'absolute h-full grid z-1',
@@ -98,7 +82,6 @@ const NieerReflection = () => {
gridTemplateColumns: `repeat(${LOBBY_LAYOUT.columnCount}, minmax(0, 1fr))`,
}}
>
{/* 琴弦层:在背景图之上、在顶部图标之下 */}
<img
className="absolute -left-65 top-0 z-2 w-[1651px] h-auto pointer-events-none"
src={getStaticImage(`${starName}/reflection/lobby/menu/琴弦`)}
@@ -106,7 +89,6 @@ const NieerReflection = () => {
/>
{LOBBY_COLUMNS.map((scene) => {
const isUnlocked = unlockedById[scene.id as keyof typeof unlockedById]
// 顶部图标只在“该模块已完成学习任务”时亮起;未完成(即当前进度)保持黑白
const isCompleted = placeCompletion[scene.id as keyof typeof placeCompletion]
const iconSrc = isCompleted ? scene.icon : scene.iconBw
const isClickable = isUnlocked || scene.id === guideSceneId
@@ -131,7 +113,6 @@ const NieerReflection = () => {
src={getStaticImage(`${starName}/reflection/lobby/${scene.cityImage}`)}
alt={scene.city}
/>
{/* 顶部图标 */}
<div
className={cn(
'relative z-3',
@@ -156,22 +137,18 @@ const NieerReflection = () => {
</div>
</div>
{/* 点击前往(当前引导场景) */}
{showGuideState && (
<>
<Image
className="animate-finger-bounce absolute bottom-[calc(50%-200px)] translate-y-1/2"
src={getStaticImage(`${starName}/reflection/lobby/icon/点击前往`)}
alt="点击前往"
/>
</>
<Image
className="animate-finger-bounce absolute bottom-[calc(50%-200px)] translate-y-1/2"
src={getStaticImage(`${starName}/reflection/lobby/icon/点击前往`)}
alt="点击前往"
/>
)}
</button>
)
})}
</div>
{/* 横跨年份层:只负责 1937 跨列展示 */}
<div
className={cn('absolute z-10 grid', LOBBY_LAYOUT.timelineTop, LOBBY_LAYOUT.columnsLeft)}
style={{
@@ -206,7 +183,6 @@ const NieerReflection = () => {
})}
</div>
{/* 底部 footer + 徽章区 */}
<div
className="absolute left-0 bottom-0 w-full h-[201px] z-20 flex justify-center items-center pt-20 gap-10"
style={{
@@ -214,23 +190,10 @@ const NieerReflection = () => {
}}
>
<div className="relative w-340 h-full">
{[
{
id: 1,
className: 'top-0 left-36',
},
{
id: 2,
className: 'top-0 left-146',
},
{
id: 3,
className: 'top-0 right-36',
},
].map((i) => {
const isLit = badgeUnlocked[i.id as keyof typeof badgeUnlocked]
{BADGE_CONFIG.map((badge) => {
const isLit = badgeUnlocked[badge.id as keyof typeof badgeUnlocked]
return (
<div key={i.id} className={cn('absolute w-[193px]', i.className)}>
<div key={badge.id} className={cn('absolute w-[193px]', badge.className)}>
<Image
width={193}
src={getStaticImage(`${starName}/reflection/lobby/icon/徽章底板`)}
@@ -240,8 +203,8 @@ const NieerReflection = () => {
<Image
className="absolute inset-0"
width={193}
src={getStaticImage(`${starName}/reflection/lobby/icon/徽章${i.id}`)}
alt={`徽章${i.id}`}
src={getStaticImage(`${starName}/reflection/lobby/icon/徽章${badge.id}`)}
alt={`徽章${badge.id}`}
/>
)}
</div>
@@ -250,7 +213,6 @@ const NieerReflection = () => {
</div>
</div>
{/* 右下角:旅行箱 */}
<Image
button
src={getStaticImage(`${starName}/reflection/lobby/icon/旅行箱`)}
@@ -259,7 +221,6 @@ const NieerReflection = () => {
onClick={() => navigate(`/${moduleName}/${starName}/reflection/box`)}
/>
{/* debug 参考线 */}
{debugLobby && (
<div
className="absolute inset-0 z-40 pointer-events-none"
@@ -274,9 +235,21 @@ const NieerReflection = () => {
<DialogLayer
state={dialogState}
onEnterUnlock={enterUnlock}
onShare={handleShare}
onShare={handleResultShare}
onFinishBadgeVideo={finishBadgeVideo}
onClose={close}
/>
<StudyGuideModal
visible={showGuideModal}
firstLineIndent="2em"
onContinue={markReflectionGuideSeen}
audioSrc={getPublicPath('audio/nieer/mp3_nieer_28.mp3')}
>
{`同学你好,欢迎来到“跨越时空·体悟”学习模块!
请登陆“时空回溯・亲历时代”体验仓,下一站:1936年,上海。
请带上你的旅行包,穿越时空解锁6个国歌故事,集齐精神徽章,绘制属于你的国歌精神图谱吧~时空门已开,请出发吧!`}
</StudyGuideModal>
</div>
)
}