Files
shanghai-residences/src/pages/nieer/reflection/index.tsx
T
zhongyidianandClaude Opus 4.7 49ecabbf3a fix: 修复开发/测试包模拟进度被易班同步覆盖的问题
进度模拟面板写入 puzzleStore 后,App 启动时易班同步会将其覆盖;
现在在同步完成后按 sessionStorage 中保存的模式重放模拟进度,
确保测试包的"到达某故居/全流程完成"调试入口生效。

同时纳入近期未提交的页面、组件、资源与环境变量改动。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 10:19:36 +08:00

362 lines
14 KiB
TypeScript

import { EndingOverlay, Image, StudyGuideModal, Video } from '@/components'
import { cn, getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { LOBBY_COLUMNS, LOBBY_LAYOUT, LOBBY_TIMELINE } from './lobby.config'
import { saveNieerStore, useNieerStore } from '@/store/nieerStore'
import { useDialogReducer } from './useDialogReducer'
import DialogLayer from './DialogLayer'
import {
BADGE_CONFIG,
getAllPlacesCompleted,
getBadgeUnlockedMap,
getGuideSceneId,
getPlaceCompletionMap,
getUnlockVideoSrcForScene,
getUnlockedByIdMap,
} from './reflectionProgress'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
const starName = 'nieer'
const NieerReflection = () => {
const navigate = useNavigate()
const location = useLocation()
const fromReflectionSubpage = !!(location.state as { fromReflectionSubpage?: boolean } | null)
?.fromReflectionSubpage
const { moduleName } = useParams<{ moduleName: string }>()
const debugLobby = new URLSearchParams(window.location.search).get('debugLobby') === '1'
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 hasSeenBoxHint = useNieerStore((state) => state.hasSeenBoxHint)
const markBoxHintSeen = useNieerStore((state) => state.markBoxHintSeen)
const [showOpeningVideo, setShowOpeningVideo] = useState(!fromReflectionSubpage)
const showGuideModal = !showOpeningVideo && !hasSeenReflectionGuide
const showBoxHint = !showOpeningVideo && !showGuideModal && !hasSeenBoxHint
// 获取碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation()
const {
state: dialogState,
openScene,
enterUnlock,
handleShare,
close,
} = useDialogReducer(markReflectionCursorVisited, setReflectionWork)
const placeCompletion = useMemo(
() => getPlaceCompletionMap(visitedReflectionCursors),
[visitedReflectionCursors]
)
const unlockedById = useMemo(() => getUnlockedByIdMap(placeCompletion), [placeCompletion])
const badgeUnlocked = useMemo(() => getBadgeUnlockedMap(placeCompletion), [placeCompletion])
const guideSceneId = useMemo(() => getGuideSceneId(placeCompletion), [placeCompletion])
const allPlacesCompleted = useMemo(
() => getAllPlacesCompleted(placeCompletion),
[placeCompletion]
)
const [endingVisible, setEndingVisible] = useState(false)
const [pendingBadgeVideoSrc, setPendingBadgeVideoSrc] = useState<string>()
const [playingBadgeVideoSrc, setPlayingBadgeVideoSrc] = useState<string>()
const [pendingEndingVisible, setPendingEndingVisible] = useState(false)
const prevDialogModeRef = useRef(dialogState.mode)
useEffect(() => {
if (prevDialogModeRef.current === 'result' && dialogState.mode === 'idle') {
if (pendingBadgeVideoSrc) {
setPlayingBadgeVideoSrc(pendingBadgeVideoSrc)
setPendingBadgeVideoSrc(undefined)
}
if (allPlacesCompleted) {
triggerPuzzleAnimation(14)
if (pendingBadgeVideoSrc) {
setPendingEndingVisible(true)
} else {
setEndingVisible(true)
}
}
}
prevDialogModeRef.current = dialogState.mode
}, [dialogState.mode, allPlacesCompleted, pendingBadgeVideoSrc, triggerPuzzleAnimation])
const handleResultShare = (sceneId: number, payload?: Record<string, unknown>) => {
const unlockVideoSrc = getUnlockVideoSrcForScene(sceneId, visitedReflectionCursors)
setPendingBadgeVideoSrc(unlockVideoSrc)
handleShare({ payload })
}
const finishBadgeVideo = () => {
setPlayingBadgeVideoSrc(undefined)
if (pendingEndingVisible) {
setPendingEndingVisible(false)
setEndingVisible(true)
}
}
if (!moduleName) return null
return (
<div
className="w-full h-full relative bg-cover bg-center bg-no-repeat overflow-hidden"
style={{ backgroundImage: getBackgroundImage(`${starName}/reflection/lobby/场景首页底图`) }}
>
{showOpeningVideo && (
<div className="fixed inset-0 z-10000 w-full h-full bg-black">
<Video
src={getPublicPath('video/reflection/nieer_tiwu_00.mp4')}
autoPlay
playsInline
showSkipButton
skipText="跳过"
onEnded={() => setShowOpeningVideo(false)}
onSkip={() => setShowOpeningVideo(false)}
onError={() => setShowOpeningVideo(false)}
/>
</div>
)}
<Image
className="absolute right-6 top-5 z-30"
src={getStaticImage('components/离开')}
button
onClick={() => navigate(`/${moduleName}/${starName}/home`)}
/>
<div
className={cn(
'absolute h-full grid z-1',
LOBBY_LAYOUT.columnsTop,
LOBBY_LAYOUT.columnsLeft
)}
style={{
width: `${LOBBY_LAYOUT.columnsWidthPx}px`,
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/琴弦`)}
alt="琴弦"
/>
{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
const showGuideState = !allPlacesCompleted && scene.id === guideSceneId
return (
<button
key={scene.id}
type="button"
className={cn(
'relative w-[220px] h-full flex flex-col items-center',
!isClickable && 'pointer-events-none cursor-not-allowed',
showGuideState && 'border-l-6 border-r-6 border-white'
)}
onClick={() => {
if (!isClickable) return
openScene(scene.id, !!isUnlocked)
}}
>
<Image
className={cn('absolute inset-0 -z-1', !isUnlocked && 'brightness-50')}
src={getStaticImage(`${starName}/reflection/lobby/${scene.cityImage}`)}
alt={scene.city}
/>
<div
className={cn(
'relative z-3',
'w-[233px] h-[233px] flex items-center justify-center -mt-13'
)}
>
<div className="relative w-[112px] h-[111px]">
{showGuideState && (
<span
className={cn(
' absolute -z-3 left-0 inline-flex h-full w-full rounded-full',
'bg-[#fcc419] opacity-100 animate-ping',
'border-2 border-white drop-shadow-2xl'
)}
/>
)}
<img
className="w-full"
src={getStaticImage(`${starName}/reflection/lobby/menu/${iconSrc}`)}
alt={scene.icon}
/>
</div>
</div>
{showGuideState && (
<Image
className="animate-finger-bounce absolute bottom-[calc(50%-200px)] translate-y-1/2"
src={getStaticImage(`${starName}/reflection/lobby/icon/点击前往`)}
alt="点击前往"
/>
)}
</button>
)
})}
</div>
<div
className={cn('absolute z-10 grid', LOBBY_LAYOUT.timelineTop, LOBBY_LAYOUT.columnsLeft)}
style={{
width: `${LOBBY_LAYOUT.columnsWidthPx}px`,
gridTemplateColumns: `repeat(${LOBBY_LAYOUT.columnCount}, minmax(0, 1fr))`,
}}
>
{LOBBY_TIMELINE.map((item) => {
if (item.type === 'single') {
return (
<div
key={`${item.type}-${item.col}-${item.label}`}
className="text-center text-4xl font-semibold text-white [text-shadow:0_0_20px_rgb(247_214_97_/0.8),0_0_20px_rgb(247_214_97/0.8)]"
style={{ gridColumn: `${item.col + 1} / span 1` }}
>
{item.label}
</div>
)
}
return (
<div
key={`${item.type}-${item.colStart}-${item.colEnd}-${item.label}`}
className="flex items-center justify-center gap-3 text-4xl font-semibold text-white [text-shadow:0_0_20px_rgb(247_214_97_/0.8),0_0_20px_rgb(247_214_97/0.8)]"
style={{ gridColumn: `${item.colStart + 1} / ${item.colEnd + 2}` }}
>
{item.withLine && <span className="h-[2px] w-24 bg-[#f5e9c7]/80" />}
<span>{item.label}</span>
{item.withLine && <span className="h-[2px] w-24 bg-[#f5e9c7]/80" />}
</div>
)
})}
</div>
<div
className="absolute left-0 bottom-0 w-full h-[201px] z-1 flex justify-center items-center pt-20 gap-10 pointer-events-none"
style={{
backgroundImage: getBackgroundImage(`${starName}/reflection/lobby/footer`),
}}
>
<div className="relative w-340 h-full">
{BADGE_CONFIG.map((badge) => {
const isLit = badgeUnlocked[badge.id as keyof typeof badgeUnlocked]
return (
<div key={badge.id} className={cn('absolute w-[193px]', badge.className)}>
<Image
width={193}
src={getStaticImage(`${starName}/reflection/lobby/icon/徽章底板`)}
alt="徽章底板"
/>
{isLit && (
<Image
className="absolute inset-0"
width={193}
src={getStaticImage(`${starName}/reflection/lobby/icon/徽章${badge.id}`)}
alt={`徽章${badge.id}`}
/>
)}
</div>
)
})}
</div>
</div>
<div className="absolute right-0 bottom-0 z-30">
{showBoxHint && (
<>
<div className="absolute -left-85 -top-45 flex flex-col items-center bg-[#3a2a1a]/85 rounded-xl px-8 py-5 border-2 border-[#fcc419] animate-pulse">
<span className="text-4xl text-white font-bold [text-shadow:_0_0_10px_rgba(252,196,25,0.8)] whitespace-nowrap">
点击「领取旅行箱」
</span>
<br />
<span className="text-3xl text-[#fcc419] font-bold [text-shadow:_0_0_10px_rgba(0,0,0,0.8)] whitespace-nowrap">
旅行箱会记录你每段旅行的成果哦!
</span>
</div>
</>
)}
<Image
button
src={getStaticImage(`${starName}/reflection/lobby/icon/旅行箱`)}
alt="旅行箱"
onClick={() => {
markBoxHintSeen()
navigate(`/${moduleName}/${starName}/reflection/box`)
}}
/>
</div>
{debugLobby && (
<div
className="absolute inset-0 z-40 pointer-events-none"
style={{
background:
'linear-gradient(to right, rgba(255,0,0,0.25) 1px, transparent 1px), linear-gradient(to bottom, rgba(0,255,255,0.18) 1px, transparent 1px)',
backgroundSize: '120px 100%, 100% 80px',
}}
/>
)}
<DialogLayer
state={dialogState}
onEnterUnlock={enterUnlock}
onShare={handleResultShare}
onClose={close}
/>
{playingBadgeVideoSrc && (
<div className="fixed inset-0 z-10000 w-full h-full bg-black">
<Video
key={playingBadgeVideoSrc}
src={playingBadgeVideoSrc}
autoPlay
playsInline
showSkipButton
skipText="跳过"
onEnded={finishBadgeVideo}
onSkip={finishBadgeVideo}
onError={finishBadgeVideo}
/>
</div>
)}
<StudyGuideModal
visible={showGuideModal}
onContinue={markReflectionGuideSeen}
audioSrc={getPublicPath('audio/nieer/mp3_nieer_28.mp3')}
firstLineIndent="2em"
>
{`同学你好,欢迎来到“跨越时空·体悟”学习模块!
请登陆“时空回溯・亲历时代”体验仓,下一站:1936年,上海。
请带上你的旅行包,穿越时空解锁6个国歌故事,集齐精神徽章,绘制属于你的国歌精神图谱吧~
别忘了右下角的旅行箱哦,它会记录你每段旅行的成果!时空门已开,请出发吧!`}
</StudyGuideModal>
<EndingOverlay
visible={endingVisible}
audioSrc={getPublicPath('audio/nieer/mp3_nieer_35.mp3')}
animationSpeed={200}
text={`恭喜你完成所有时空旅行!成功绘制“国歌精神图谱”,解锁6个跨越时空的国歌故事~国歌的故事还在继续,它的精神藏在每一次唱响、每一次坚守中。愿你带着这份精神力量,做国歌的传承者、爱国的践行者!`}
onContinue={() => {
setEndingVisible(false)
saveNieerStore()
setCourseTimeProgress(14)
navigate(`/${moduleName}/${starName}/home`)
}}
/>
</div>
)
}
export default NieerReflection