进度模拟面板写入 puzzleStore 后,App 启动时易班同步会将其覆盖; 现在在同步完成后按 sessionStorage 中保存的模式重放模拟进度, 确保测试包的"到达某故居/全流程完成"调试入口生效。 同时纳入近期未提交的页面、组件、资源与环境变量改动。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
362 lines
14 KiB
TypeScript
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
|