feat: add progression logic for puzzle residences

- Implemented functions to manage puzzle progress including checking if residences are started, complete, and unlocked.
- Added utility functions to retrieve piece IDs based on residence and route.
- Introduced a new TypeScript file for progression management.
- Integrated puzzle data configuration and residence data for seamless progression tracking.
This commit is contained in:
zhongyidian committed 2026-05-01 21:19:25 +08:00
1 parent 5a1c3cc368
commit 284302341c
72 files changed
+1104 -517

No files matched your search

+9 -1
View File
@@ -45,7 +45,15 @@
"Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 show main:src/pages/zhangleping/home.tsx)", "Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 show main:src/pages/zhangleping/home.tsx)",
"Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 show main:src/store/storeNames.ts)", "Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 show main:src/store/storeNames.ts)",
"Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 grep -n \"getUserDefine\" main -- src/pages/zhangleping src/store/zhanglepingStore.ts src/App.tsx)", "Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 grep -n \"getUserDefine\" main -- src/pages/zhangleping src/store/zhanglepingStore.ts src/App.tsx)",
"Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 grep -n \"setDrawArtwork\\\\|clearDrawArtwork\\\\|drawArtworkDataUrl\" main -- src)" "Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 grep -n \"setDrawArtwork\\\\|clearDrawArtwork\\\\|drawArtworkDataUrl\" main -- src)",
"Bash(git grep *)",
"Bash(git commit -m ' *)",
"Bash(bun ls-files *)",
"Bash(bunx prettier *)",
"Bash(awk 'NR>=216 && NR<=380' src/config/puzzleData.ts)",
"Bash(awk 'NR>=380 && NR<=540' src/config/puzzleData.ts)",
"Bash(awk '/^worktree / {wt=$2} /^branch / {br=$2; if \\(wt ~ /\\\\.claude\\\\/worktrees\\\\/agent-/\\) print wt\"\\\\t\"br}')",
"Read(//tmp/**)"
], ],
"deny": [ "deny": [
"Read(public/**)", "Read(public/**)",
+10 -7
View File
@@ -38,16 +38,19 @@ function AppContent() {
;(async () => { ;(async () => {
try { try {
const raw = await getUserDefine(PUZZLE_LESSON_ID, PUZZLE_KEY) const raw = await getUserDefine(PUZZLE_LESSON_ID, PUZZLE_KEY)
if (!raw) return if (raw) {
const data = JSON.parse(raw) as { puzzles?: Record<string, number[]> } const data = JSON.parse(raw) as { puzzles?: Record<string, number[]> }
if (data.puzzles) { if (data.puzzles) {
usePuzzleStore.setState((prev) => ({ usePuzzleStore.setState((prev) => ({
...prev, ...prev,
puzzles: deserializePuzzles(data.puzzles), puzzles: deserializePuzzles(data.puzzles),
})) }))
}
} }
} catch (e) { } catch (e) {
console.error('Failed to load puzzle store', e) console.error('Failed to load puzzle store', e)
} finally {
usePuzzleStore.getState().setLoaded(true)
} }
})() })()
}, []) }, [])
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -1,4 +1,4 @@
import { useEffect } from 'react' import { useEffect, useRef } from 'react'
import { cn, getBackgroundImage } from '@/lib/utils' import { cn, getBackgroundImage } from '@/lib/utils'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import { useAudioManager } from '@/hooks/useAudioManager' import { useAudioManager } from '@/hooks/useAudioManager'
@@ -40,19 +40,35 @@ const ExhibitDescriptionNarration = ({
// 当前组件对应的音频是否正在播放 // 当前组件对应的音频是否正在播放
const isCurrentAudioPlaying = audioSrc && currentSrc === audioSrc && isPlaying const isCurrentAudioPlaying = audioSrc && currentSrc === audioSrc && isPlaying
// 标记本实例是否发起过播放,用于在不可见/卸载时可靠停止
// 不依赖 currentSrc,避免 audio.play() Promise 未 resolve 前关闭弹窗导致漏停
const playInitiatedRef = useRef(false)
// 自动播放音频 // 自动播放音频
useEffect(() => { useEffect(() => {
if (visible && autoPlay && audioSrc) { if (visible && autoPlay && audioSrc) {
playInitiatedRef.current = true
play(audioSrc) play(audioSrc)
} }
}, [visible, autoPlay, audioSrc, play]) }, [visible, autoPlay, audioSrc, play])
// 组件不可见时停止音频(仅当当前播放的是该组件的 audioSrc) // 组件不可见时停止音频
useEffect(() => { useEffect(() => {
if (!visible && audioSrc && currentSrc === audioSrc) { if (!visible && playInitiatedRef.current) {
stop() stop()
playInitiatedRef.current = false
} }
}, [visible, audioSrc, currentSrc, stop]) }, [visible, stop])
// 组件卸载时兜底停止
useEffect(() => {
return () => {
if (playInitiatedRef.current) {
stop()
playInitiatedRef.current = false
}
}
}, [stop])
if (!text) return null if (!text) return null
@@ -98,7 +114,10 @@ const ExhibitDescriptionNarration = ({
<div className="flex items-center justify-end gap-2 flex-shrink-0"> <div className="flex items-center justify-end gap-2 flex-shrink-0">
{!isCurrentAudioPlaying && ( {!isCurrentAudioPlaying && (
<button <button
onClick={() => play(audioSrc)} onClick={() => {
playInitiatedRef.current = true
play(audioSrc)
}}
className="w-10 h-10 flex items-center justify-center rounded-full bg-white/30 hover:bg-white/50 transition-colors shadow-lg" className="w-10 h-10 flex items-center justify-center rounded-full bg-white/30 hover:bg-white/50 transition-colors shadow-lg"
aria-label="播放" aria-label="播放"
> >
@@ -109,7 +128,10 @@ const ExhibitDescriptionNarration = ({
)} )}
{isCurrentAudioPlaying && ( {isCurrentAudioPlaying && (
<button <button
onClick={stop} onClick={() => {
stop()
playInitiatedRef.current = false
}}
className="w-10 h-10 flex items-center justify-center rounded-full bg-white/30 hover:bg-white/50 transition-colors shadow-lg" className="w-10 h-10 flex items-center justify-center rounded-full bg-white/30 hover:bg-white/50 transition-colors shadow-lg"
aria-label="停止" aria-label="停止"
> >
+35 -14
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from 'react' import type { CSSProperties, ReactNode } from 'react'
import { useEffect, useState } from 'react' import { Children, useEffect, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { Image } from '@/components' import { Image } from '@/components'
import { cn, getStaticImage } from '@/lib/utils' import { cn, getStaticImage } from '@/lib/utils'
@@ -7,6 +7,35 @@ import { useScale } from '@/contexts/ScaleContext'
import { useAudioManager } from '@/hooks/useAudioManager' import { useAudioManager } from '@/hooks/useAudioManager'
import { useVideoContextSafe } from '@/contexts/VideoContext' import { useVideoContextSafe } from '@/contexts/VideoContext'
const toCssSize = (value: string | number) => (typeof value === 'number' ? `${value}px` : value)
const renderIndentedContent = (children: ReactNode, firstLineIndent?: string | number) => {
if (firstLineIndent === undefined) return children
const childArray = Children.toArray(children)
const canSplitIntoParagraphs = childArray.every(
(child) => typeof child === 'string' || typeof child === 'number'
)
if (!canSplitIntoParagraphs) return children
const paragraphs = childArray
.join('')
.split(/\n+/)
.map((paragraph) => paragraph.trim())
.filter(Boolean)
if (paragraphs.length === 0) return children
const paragraphStyle: CSSProperties = { textIndent: toCssSize(firstLineIndent) }
return paragraphs.map((paragraph, index) => (
<p key={index} style={paragraphStyle}>
{paragraph}
</p>
))
}
export interface StudyGuideModalProps { export interface StudyGuideModalProps {
/** 是否可见 */ /** 是否可见 */
visible?: boolean visible?: boolean
@@ -120,6 +149,8 @@ export const StudyGuideModal = ({
// } // }
} }
const contentChildren = renderIndentedContent(children, firstLineIndent)
const content = ( const content = (
<div <div
className="fixed inset-0 flex items-center justify-center" className="fixed inset-0 flex items-center justify-center"
@@ -158,21 +189,11 @@ export const StudyGuideModal = ({
)} )}
style={{ style={{
marginTop: verticalAlign === 'center' ? '85px' : '', marginTop: verticalAlign === 'center' ? '85px' : '',
textIndent:
firstLineIndent === undefined
? undefined
: typeof firstLineIndent === 'number'
? `${firstLineIndent}px`
: firstLineIndent,
paddingLeft: paddingLeft:
contentPaddingLeft === undefined contentPaddingLeft === undefined ? undefined : toCssSize(contentPaddingLeft),
? undefined
: typeof contentPaddingLeft === 'number'
? `${contentPaddingLeft}px`
: contentPaddingLeft,
}} }}
> >
{children} {contentChildren}
</div> </div>
)} )}
</div> </div>
+26 -4
View File
@@ -136,10 +136,17 @@ const ResidenceLayoutContent = () => {
return true return true
}, },
[ANIMATION_CONFIG.FLY_DURATION, ANIMATION_CONFIG.OPACITY, ANIMATION_CONFIG.SCALE, cleanupAnimation, puzzleBoxRef, residence, unlockPuzzle] [
ANIMATION_CONFIG.FLY_DURATION,
ANIMATION_CONFIG.OPACITY,
ANIMATION_CONFIG.SCALE,
cleanupAnimation,
puzzleBoxRef,
residence,
unlockPuzzle,
]
) )
// 等待元素准备就绪并执行动画 // 等待元素准备就绪并执行动画
const waitForElementsAndAnimate = useCallback( const waitForElementsAndAnimate = useCallback(
async (pieceId: number, retryCount = 0): Promise<void> => { async (pieceId: number, retryCount = 0): Promise<void> => {
@@ -165,7 +172,15 @@ const ResidenceLayoutContent = () => {
unlockPuzzle(residence, pieceId) unlockPuzzle(residence, pieceId)
cleanupAnimation() cleanupAnimation()
}, },
[ANIMATION_CONFIG.MAX_RETRIES, ANIMATION_CONFIG.RETRY_DELAY, cleanupAnimation, executeFlyAnimation, puzzleBoxRef, residence, unlockPuzzle] [
ANIMATION_CONFIG.MAX_RETRIES,
ANIMATION_CONFIG.RETRY_DELAY,
cleanupAnimation,
executeFlyAnimation,
puzzleBoxRef,
residence,
unlockPuzzle,
]
) )
// 处理碎片动画触发(仅支持拼图功能的故居) // 处理碎片动画触发(仅支持拼图功能的故居)
@@ -215,7 +230,14 @@ const ResidenceLayoutContent = () => {
}) })
}) })
}, },
[ANIMATION_CONFIG.APPEAR_DURATION, ANIMATION_CONFIG.GLOW_DURATION, hasPuzzleFeature, isPuzzleUnlocked, residence, waitForElementsAndAnimate] [
ANIMATION_CONFIG.APPEAR_DURATION,
ANIMATION_CONFIG.GLOW_DURATION,
hasPuzzleFeature,
isPuzzleUnlocked,
residence,
waitForElementsAndAnimate,
]
) )
// 获取当前路由的视频路径 // 获取当前路由的视频路径
+14 -3
View File
@@ -442,19 +442,28 @@ const LetterTemplate = ({
<div <div
className="relative w-[1200px] h-full pointer-events-auto" className="relative w-[1200px] h-full pointer-events-auto"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
style={{ perspective: '1200px' }} style={{
perspective: '1200px',
WebkitPerspective: '1200px',
}}
> >
<motion.div <motion.div
className="relative w-full h-full" className="relative w-full h-full"
initial={false} initial={false}
animate={{ rotateY: submitted ? 180 : 0 }} animate={{ rotateY: submitted ? 180 : 0 }}
transition={{ duration: 0.7, ease: 'easeInOut' }} transition={{ duration: 0.7, ease: 'easeInOut' }}
style={{ transformStyle: 'preserve-3d' }} style={{
transformStyle: 'preserve-3d',
WebkitTransformStyle: 'preserve-3d',
}}
> >
<div <div
className="absolute inset-0" className="absolute inset-0"
style={{ style={{
backfaceVisibility: 'hidden', backfaceVisibility: 'hidden',
WebkitBackfaceVisibility: 'hidden',
transform: 'translateZ(0)',
WebkitTransform: 'translateZ(0)',
pointerEvents: submitted ? 'none' : 'auto', pointerEvents: submitted ? 'none' : 'auto',
}} }}
> >
@@ -510,8 +519,10 @@ const LetterTemplate = ({
<div <div
className="absolute inset-0" className="absolute inset-0"
style={{ style={{
transform: 'rotateY(180deg)', transform: 'rotateY(180deg) translateZ(0)',
WebkitTransform: 'rotateY(180deg) translateZ(0)',
backfaceVisibility: 'hidden', backfaceVisibility: 'hidden',
WebkitBackfaceVisibility: 'hidden',
pointerEvents: submitted ? 'auto' : 'none', pointerEvents: submitted ? 'auto' : 'none',
}} }}
> >
+1 -1
View File
@@ -204,7 +204,7 @@ const MenuPuzzleCenter = ({
/> />
)} )}
<Image <Image
src={getStaticImage(`puzzle/home/导图`)} src={getStaticImage(`puzzle/home/追光入口`)}
alt="导图" alt="导图"
button button
onClick={(e) => { onClick={(e) => {
+79
View File
@@ -0,0 +1,79 @@
import { puzzleDataConfig } from './puzzleData'
import { PUZZLE_RESIDENCES } from './puzzleResidences'
export type PuzzleProgress = Record<string, Set<number>>
const getResidenceIndex = (residence: string) =>
PUZZLE_RESIDENCES.findIndex((item) => item.residence === residence)
const getConfiguredPieceIds = (residence: string) => {
const pieces = puzzleDataConfig[residence]
if (!pieces) return []
return Object.keys(pieces).map(Number)
}
const arePiecesUnlocked = (residence: string, puzzles: PuzzleProgress, pieceIds: number[]) => {
if (pieceIds.length === 0) return false
const unlockedPieces = puzzles[residence]
if (!unlockedPieces) return false
return pieceIds.every((pieceId) => unlockedPieces.has(pieceId))
}
export const isResidenceStarted = (residence: string, puzzles: PuzzleProgress) => {
return (puzzles[residence]?.size ?? 0) > 0
}
export const isResidenceComplete = (residence: string, puzzles: PuzzleProgress) => {
return arePiecesUnlocked(residence, puzzles, getConfiguredPieceIds(residence))
}
export const isResidenceUnlocked = (residence: string, puzzles: PuzzleProgress) => {
const index = getResidenceIndex(residence)
if (index === 0) return true
if (index < 0) return false
const previousResidence = PUZZLE_RESIDENCES[index - 1]
if (!previousResidence) return false
return isResidenceComplete(previousResidence.residence, puzzles)
}
export const isMenuModuleUnlocked = (moduleName: string, puzzles: PuzzleProgress) => {
return PUZZLE_RESIDENCES.some(
(item) => item.moduleName === moduleName && isResidenceUnlocked(item.residence, puzzles)
)
}
export const isMenuModuleStarted = (moduleName: string, puzzles: PuzzleProgress) => {
return PUZZLE_RESIDENCES.some(
(item) => item.moduleName === moduleName && isResidenceStarted(item.residence, puzzles)
)
}
export const isMenuModuleComplete = (moduleName: string, puzzles: PuzzleProgress) => {
const residences = PUZZLE_RESIDENCES.filter((item) => item.moduleName === moduleName)
if (residences.length === 0) return false
return residences.every((item) => isResidenceComplete(item.residence, puzzles))
}
const getPieceIdsByRoute = (residence: string, routePrefix: string) => {
const pieces = puzzleDataConfig[residence]
if (!pieces) return []
return Object.entries(pieces)
.filter(([, piece]) => piece.route.startsWith(routePrefix))
.map(([pieceId]) => Number(pieceId))
}
export const getResidenceStageUnlocks = (residence: string, puzzles: PuzzleProgress) => {
const residenceUnlocked = isResidenceUnlocked(residence, puzzles)
return {
dialogueUnlocked: residenceUnlocked,
reflectionUnlocked:
residenceUnlocked &&
arePiecesUnlocked(residence, puzzles, getPieceIdsByRoute(residence, 'place')),
internalizationUnlocked:
residenceUnlocked &&
arePiecesUnlocked(residence, puzzles, getPieceIdsByRoute(residence, 'reflection')),
}
}
+34 -7
View File
@@ -9,6 +9,11 @@ declare function getFutureDa(days: number): string
declare function getCurrentDa(str: string): void declare function getCurrentDa(str: string): void
declare function formatDateFromMilliseconds(date: Date): string declare function formatDateFromMilliseconds(date: Date): string
// 本地或非易班环境下,外部全局变量可能未注入;统一用安全访问
const isYibanRuntime = (): boolean => {
return typeof iscallyb !== 'undefined' && iscallyb === true
}
interface UserDefineParam { interface UserDefineParam {
lessonId: string lessonId: string
key?: string | number key?: string | number
@@ -72,8 +77,8 @@ $.extend({
const LESSON_PREFIX = 'hsgrl' const LESSON_PREFIX = 'hsgrl'
const videoajax: VideoAjax = videoAjax() const videoajax: VideoAjax | null = typeof videoAjax !== 'undefined' ? videoAjax() : null
videoajax.setHost('/') videoajax?.setHost('/')
function normalizeLessonId(lessonId: string | number): string { function normalizeLessonId(lessonId: string | number): string {
const raw = String(lessonId) const raw = String(lessonId)
@@ -82,7 +87,7 @@ function normalizeLessonId(lessonId: string | number): string {
// 设置我的自定义数据(所有的自定义数据都设置在第一个课时ID上,以key来区分不同的类型) // 设置我的自定义数据(所有的自定义数据都设置在第一个课时ID上,以key来区分不同的类型)
function setLtDefine(key: string | number, value: string): void { function setLtDefine(key: string | number, value: string): void {
if (iscallyb) { if (isYibanRuntime()) {
setUserDefine(1, key, value) setUserDefine(1, key, value)
} else { } else {
sessionStorage.setItem(`${LESSON_PREFIX}_${key}`, value) sessionStorage.setItem(`${LESSON_PREFIX}_${key}`, value)
@@ -93,7 +98,7 @@ let mycstr9 = ''
// 获取定义数据(由于底层是异步接口,这里返回 Promise) // 获取定义数据(由于底层是异步接口,这里返回 Promise)
async function getLtDefine(key: string | number): Promise<string> { async function getLtDefine(key: string | number): Promise<string> {
if (iscallyb) { if (isYibanRuntime()) {
if (mycstr9 === '') { if (mycstr9 === '') {
const all = await getUsersDefine(1, emykey) const all = await getUsersDefine(1, emykey)
if (all === undefined || all === null || all === 'null' || all === '') { if (all === undefined || all === null || all === 'null' || all === '') {
@@ -137,6 +142,11 @@ export function setUserDefine(
key: string | number, key: string | number,
value: string value: string
): void { ): void {
if (!isYibanRuntime() || !videoajax) {
sessionStorage.setItem(`${normalizeLessonId(lessonId)}_${key}`, value)
return
}
const param: UserDefineParam = { const param: UserDefineParam = {
lessonId: normalizeLessonId(lessonId), lessonId: normalizeLessonId(lessonId),
key, key,
@@ -150,14 +160,20 @@ function getUserDefineAsyncInternal(
lessonId: string | number, lessonId: string | number,
key: string | number key: string | number
): Promise<string> { ): Promise<string> {
if (!isYibanRuntime() || !videoajax) {
const value = sessionStorage.getItem(`${normalizeLessonId(lessonId)}_${key}`)
return Promise.resolve(value === null || value === 'null' ? '' : value)
}
const param: UserDefineParam = { const param: UserDefineParam = {
lessonId: normalizeLessonId(lessonId), lessonId: normalizeLessonId(lessonId),
order: key, order: key,
} }
const ajax = videoajax
return new Promise((resolve) => { return new Promise((resolve) => {
let content = '' let content = ''
videoajax.getUserDefineInfoByUser(param, (res) => { ajax.getUserDefineInfoByUser(param, (res) => {
// 保留原有 console 行为 // 保留原有 console 行为
console.log(res) console.log(res)
if (res && res.code === 200) { if (res && res.code === 200) {
@@ -185,14 +201,19 @@ function getUsersDefineAsyncInternal(
lessonId: string | number, lessonId: string | number,
key: string | number key: string | number
): Promise<string | VideoAjaxResponseItem[]> { ): Promise<string | VideoAjaxResponseItem[]> {
if (!isYibanRuntime() || !videoajax) {
return Promise.resolve('')
}
const param: UserDefineParam = { const param: UserDefineParam = {
lessonId: normalizeLessonId(lessonId), lessonId: normalizeLessonId(lessonId),
order: key, order: key,
} }
const ajax = videoajax
return new Promise((resolve) => { return new Promise((resolve) => {
let content: string | VideoAjaxResponseItem[] = '' let content: string | VideoAjaxResponseItem[] = ''
videoajax.getUserDefineInfo(param, (data) => { ajax.getUserDefineInfo(param, (data) => {
if (data && data.code === 200) { if (data && data.code === 200) {
const json = (data.data.value || []) as VideoAjaxResponseItem[] const json = (data.data.value || []) as VideoAjaxResponseItem[]
if (json.length > 1) { if (json.length > 1) {
@@ -232,7 +253,13 @@ export async function setCourseTimeProgress(order: number): Promise<void> {
// var courseid = getCookie(lesson+"_course_id");//获取url链接的课程ID // var courseid = getCookie(lesson+"_course_id");//获取url链接的课程ID
// const courseid = await getLtDefine('10000') // const courseid = await getLtDefine('10000')
console.info(`courseid:${courseid} order:${order}`) console.info(`courseid:${courseid} order:${order}`)
if (iscallyb && courseid !== undefined && courseid !== null && courseid !== '') { if (
isYibanRuntime() &&
videoajax &&
courseid !== undefined &&
courseid !== null &&
courseid !== ''
) {
if (order > 0) { if (order > 0) {
const param2 = { order, co: courseid, process: 100 } const param2 = { order, co: courseid, process: 100 }
videoajax.finishCourse(param2) videoajax.finishCourse(param2)
+36 -14
View File
@@ -4,11 +4,15 @@ import { Image, PuzzleSystem } from '@/components'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import MenuPuzzleCenter from '@/components/puzzle/MenuPuzzleCenter' import MenuPuzzleCenter from '@/components/puzzle/MenuPuzzleCenter'
import { DEFAULT_PUZZLE_RESIDENCE } from '@/config/puzzleResidences' import { DEFAULT_PUZZLE_RESIDENCE } from '@/config/puzzleResidences'
import { usePuzzleStore } from '@/store/puzzleStore'
import { isMenuModuleComplete } from '@/config/progression'
const Menu = () => { const Menu = () => {
const navigate = useNavigate() const navigate = useNavigate()
const [isPuzzleCenterVisible, setIsPuzzleCenterVisible] = useState(false) const [isPuzzleCenterVisible, setIsPuzzleCenterVisible] = useState(false)
const [activeResidence, setActiveResidence] = useState<string | null>(null) const [activeResidence, setActiveResidence] = useState<string | null>(null)
const puzzles = usePuzzleStore((state) => state.puzzles)
const isLoaded = usePuzzleStore((state) => state.isLoaded)
/** /**
* 类型:1:革命先驱、文化民人、艺术名家、经济巨擘 * 类型:1:革命先驱、文化民人、艺术名家、经济巨擘
@@ -84,23 +88,41 @@ const Menu = () => {
)} )}
<div className="absolute left-40 top-70 inline-grid grid-cols-2 gap-x-20 gap-y-10"> <div className="absolute left-40 top-70 inline-grid grid-cols-2 gap-x-20 gap-y-10">
{Category.map((item) => ( {Category.map((item) => {
<div const isComplete = isLoaded && isMenuModuleComplete(item.moduleName, puzzles)
className="relative w-[241px] h-[282px] bg-cover bg-center bg-no-repeat hover:brightness-110 hover:scale-105 transition-all cursor-pointer" const completeImageMap: Record<string, string> = {
style={{ backgroundImage: getBackgroundImage('menu/模块-通用') }} revolutionary: 'menu/革命完成',
key={item.id} cultural: 'menu/文化完成',
onClick={() => navigate(`/entrance/${encodeURIComponent(item.moduleName)}`)} artistic: 'menu/艺术完成',
> economic: 'menu/经济完成',
<div className="absolute top-15 flex flex-col items-center"> }
<Image key={item.id} src={getStaticImage(item.image)} />
<div className="relative mt-23"> return (
<Image className="absolute bottom-5" src={getStaticImage('menu/未看完')} /> <div
<Image src={getStaticImage('menu/未探索')} /> className="relative w-[241px] h-[282px] bg-cover bg-center bg-no-repeat hover:brightness-110 hover:scale-105 transition-all cursor-pointer"
style={{ backgroundImage: getBackgroundImage('menu/模块-通用') }}
key={item.id}
onClick={() => navigate(`/entrance/${encodeURIComponent(item.moduleName)}`)}
>
<div className="absolute top-15 flex flex-col items-center">
<Image key={item.id} src={getStaticImage(item.image)} />
<div className="relative">
{isComplete ? (
<Image
src={getStaticImage(completeImageMap[item.moduleName] ?? 'menu/未探索')}
/>
) : (
<div className="mt-23">
<Image className="absolute bottom-5" src={getStaticImage('menu/未看完')} />
<Image src={getStaticImage('menu/未探索')} />
</div>
)}
</div>
</div> </div>
</div> </div>
</div> )
))} })}
</div> </div>
{/* 学习地图 */} {/* 学习地图 */}
+33 -6
View File
@@ -2,6 +2,8 @@ import { useEffect } from 'react'
import { getBackgroundImage, getStaticImage } from '@/lib/utils' import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components' import { Image } from '@/components'
import { usePuzzleStore } from '@/store/puzzleStore'
import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression'
import { getUserDefine } from '@/lib/utils/api-common' import { getUserDefine } from '@/lib/utils/api-common'
import { useChenyunStore, CHENYUN_LESSON_ID, CHENYUN_KEY } from '@/store/chenyunStore' import { useChenyunStore, CHENYUN_LESSON_ID, CHENYUN_KEY } from '@/store/chenyunStore'
@@ -27,6 +29,17 @@ const Chenyun = () => {
const lastVisitedPlace = useChenyunStore((state) => state.lastVisitedPlace) const lastVisitedPlace = useChenyunStore((state) => state.lastVisitedPlace)
const puzzles = usePuzzleStore((state) => state.puzzles)
const isLoaded = usePuzzleStore((state) => state.isLoaded)
const { dialogueUnlocked, reflectionUnlocked, internalizationUnlocked } =
getResidenceStageUnlocks(starName, puzzles)
useEffect(() => {
if (isLoaded && !isResidenceUnlocked(starName, puzzles)) {
navigate('/menu', { replace: true })
}
}, [isLoaded, navigate, puzzles, starName])
useEffect(() => { useEffect(() => {
;(async () => { ;(async () => {
try { try {
@@ -77,21 +90,35 @@ const Chenyun = () => {
<div className="absolute right-20 top-75 flex flex-col space-y-10"> <div className="absolute right-20 top-75 flex flex-col space-y-10">
{/* 对话 */} {/* 对话 */}
<Image <Image
button button={dialogueUnlocked}
className={dialogueUnlocked ? undefined : 'opacity-50 grayscale cursor-not-allowed'}
src={getStaticImage('star/一-解锁')} src={getStaticImage('star/一-解锁')}
onClick={() => navigate(`/${moduleName}/${starName}/${lastVisitedPlace || 'place1'}`)} onClick={() => {
if (!dialogueUnlocked) return
navigate(`/${moduleName}/${starName}/${lastVisitedPlace || 'place1'}`)
}}
/> />
{/* 体悟 */} {/* 体悟 */}
<Image <Image
button button={reflectionUnlocked}
className={reflectionUnlocked ? undefined : 'opacity-50 grayscale cursor-not-allowed'}
src={getStaticImage('star/二-解锁')} src={getStaticImage('star/二-解锁')}
onClick={() => navigate(`/${moduleName}/${starName}/reflection`)} onClick={() => {
if (!reflectionUnlocked) return
navigate(`/${moduleName}/${starName}/reflection`)
}}
/> />
{/* 内化 */} {/* 内化 */}
<Image <Image
button button={internalizationUnlocked}
className={
internalizationUnlocked ? undefined : 'opacity-50 grayscale cursor-not-allowed'
}
src={getStaticImage('star/三-解锁')} src={getStaticImage('star/三-解锁')}
onClick={() => navigate(`/${moduleName}/${starName}/internalization`)} onClick={() => {
if (!internalizationUnlocked) return
navigate(`/${moduleName}/${starName}/internalization`)
}}
/> />
</div> </div>
</div> </div>
+1
View File
@@ -378,6 +378,7 @@ const ChenyunInternalization = () => {
<Narration <Narration
visible={narrationVisible} visible={narrationVisible}
audioChannelId="chenyun-internalization-narration" audioChannelId="chenyun-internalization-narration"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_36.mp3')}
onClose={() => { onClose={() => {
setNarrationVisible(false) setNarrationVisible(false)
setVisible(true) setVisible(true)
+1 -1
View File
@@ -9,7 +9,7 @@ import {
} from '@/components' } from '@/components'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils' import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useState, useEffect } from 'react' import { useState } from 'react'
import Cursor from '@/components/cursor' import Cursor from '@/components/cursor'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext' import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { useChenyunStore, saveChenyunStore } from '@/store/chenyunStore' import { useChenyunStore, saveChenyunStore } from '@/store/chenyunStore'
+84 -27
View File
@@ -1,11 +1,14 @@
import { getBackgroundImage, getStaticImage } from '@/lib/utils' import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components' import { Image } from '@/components'
import { isResidenceStarted, isResidenceUnlocked } from '@/config/progression'
import { usePuzzleStore } from '@/store/puzzleStore'
//英文命名 "革命先驱" | "文化名人" | "艺术名家" | "经济巨擘" //英文命名 "革命先驱" | "文化名人" | "艺术名家" | "经济巨擘"
type ModuleName = 'revolutionary' | 'cultural' | 'artistic' | 'economic' type ModuleName = 'revolutionary' | 'cultural' | 'artistic' | 'economic'
type EntranceActionState = 'locked' | 'unvisited' | 'visited'
type EntranceSide = 'left' | 'right'
interface Star { interface Star {
image: string
name: string name: string
starName: string starName: string
} }
@@ -19,6 +22,36 @@ interface EntranceBackground {
} }
} }
const entranceActionConfig: Record<
EntranceActionState,
{
image: string
position: Record<EntranceSide, string>
}
> = {
locked: {
image: 'entrance/暂未解锁',
position: {
left: 'absolute left-95 bottom-58',
right: 'absolute right-106 bottom-58',
},
},
unvisited: {
image: 'entrance/点击前往浏览',
position: {
left: 'absolute left-90.5 bottom-37',
right: 'absolute right-90.5 bottom-37',
},
},
visited: {
image: 'entrance/该故居已访问',
position: {
left: 'absolute left-95 bottom-58',
right: 'absolute right-102 bottom-58',
},
},
}
/** /**
* 故居入口页 * 故居入口页
* 根据模块名称显示不同的背景图 * 根据模块名称显示不同的背景图
@@ -34,12 +67,10 @@ const EntranceBackgroundMap: Record<ModuleName, EntranceBackground> = {
backgroundImage: getBackgroundImage('entrance/革命先驱'), backgroundImage: getBackgroundImage('entrance/革命先驱'),
Star: { Star: {
Star1: { Star1: {
image: getStaticImage('entrance/毛泽东故居入口'),
name: '毛泽东', name: '毛泽东',
starName: 'maozedong', starName: 'maozedong',
}, },
Star2: { Star2: {
image: getStaticImage('entrance/孙中山故居入口-解锁'),
name: '孙中山', name: '孙中山',
starName: 'sunzhongshan', starName: 'sunzhongshan',
}, },
@@ -49,12 +80,10 @@ const EntranceBackgroundMap: Record<ModuleName, EntranceBackground> = {
backgroundImage: getBackgroundImage('entrance/文教名人'), backgroundImage: getBackgroundImage('entrance/文教名人'),
Star: { Star: {
Star1: { Star1: {
image: getStaticImage('entrance/鲁迅故居-解锁'),
name: '鲁迅', name: '鲁迅',
starName: 'luxun', starName: 'luxun',
}, },
Star2: { Star2: {
image: getStaticImage('entrance/黄元培-解锁'),
name: '黄炎培', name: '黄炎培',
starName: 'huangyanpei', starName: 'huangyanpei',
}, },
@@ -64,12 +93,10 @@ const EntranceBackgroundMap: Record<ModuleName, EntranceBackground> = {
backgroundImage: getBackgroundImage('entrance/艺术名家'), backgroundImage: getBackgroundImage('entrance/艺术名家'),
Star: { Star: {
Star1: { Star1: {
image: getStaticImage('entrance/聂耳亮'),
name: '聂耳', name: '聂耳',
starName: 'nieer', starName: 'nieer',
}, },
Star2: { Star2: {
image: getStaticImage('entrance/张乐平亮'),
name: '张乐平', name: '张乐平',
starName: 'zhangleping', starName: 'zhangleping',
}, },
@@ -79,12 +106,10 @@ const EntranceBackgroundMap: Record<ModuleName, EntranceBackground> = {
backgroundImage: getBackgroundImage('entrance/经济巨擎'), backgroundImage: getBackgroundImage('entrance/经济巨擎'),
Star: { Star: {
Star1: { Star1: {
image: getStaticImage('entrance/荣德生-解锁'),
name: '荣德生', name: '荣德生',
starName: 'rongdesheng', starName: 'rongdesheng',
}, },
Star2: { Star2: {
image: getStaticImage('entrance/陈云故居-解锁'),
name: '陈云', name: '陈云',
starName: 'chenyun', starName: 'chenyun',
}, },
@@ -95,12 +120,39 @@ const EntranceBackgroundMap: Record<ModuleName, EntranceBackground> = {
const Entrance = () => { const Entrance = () => {
const { moduleName } = useParams() const { moduleName } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const puzzles = usePuzzleStore((state) => state.puzzles)
// const decodedModuleName = decodeURIComponent(moduleName || ""); // const decodedModuleName = decodeURIComponent(moduleName || "");
const { backgroundImage, Star } = EntranceBackgroundMap[moduleName as ModuleName] || {} const { backgroundImage, Star } = EntranceBackgroundMap[moduleName as ModuleName] || {}
if (!Star) { if (!Star) {
return null return null
} }
const { Star1, Star2 } = Star const { Star1, Star2 } = Star
const isStar1Unlocked = isResidenceUnlocked(Star1.starName, puzzles)
const isStar2Unlocked = isResidenceUnlocked(Star2.starName, puzzles)
const isStar1Started = isResidenceStarted(Star1.starName, puzzles)
const isStar2Started = isResidenceStarted(Star2.starName, puzzles)
const getStarImage = (star: Star, isUnlocked: boolean) =>
getStaticImage(`entrance/${star.name}-${isUnlocked ? '解锁' : '未解锁'}`)
const getEntranceActionState = (isUnlocked: boolean, isStarted: boolean): EntranceActionState => {
if (!isUnlocked) return 'locked'
return isStarted ? 'visited' : 'unvisited'
}
const getEntranceAction = (side: EntranceSide, state: EntranceActionState) => {
const config = entranceActionConfig[state]
return {
className: config.position[side],
image: config.image,
}
}
const star1Action = getEntranceAction(
'left',
getEntranceActionState(isStar1Unlocked, isStar1Started)
)
const star2Action = getEntranceAction(
'right',
getEntranceActionState(isStar2Unlocked, isStar2Started)
)
return ( return (
<div className="w-full h-full bg-cover bg-center bg-no-repeat" style={{ backgroundImage }}> <div className="w-full h-full bg-cover bg-center bg-no-repeat" style={{ backgroundImage }}>
<Image <Image
@@ -110,28 +162,33 @@ const Entrance = () => {
onClick={() => navigate('/menu')} onClick={() => navigate('/menu')}
/> />
{/* 名人1 */} {/* 名人1 */}
<Image className="absolute left-73 top-[calc(50%+38px)] -translate-y-1/2" src={Star1.image} /> <Image
{Star1.image && ( className="absolute left-73 top-[calc(50%+38px)] -translate-y-1/2"
<Image src={getStarImage(Star1, isStar1Unlocked)}
className="absolute left-90.5 bottom-40" />
src={getStaticImage('entrance/点击前往浏览')} <Image
button className={star1Action.className}
onClick={() => navigate(`/${moduleName}/${Star1.starName}/home`)} src={getStaticImage(star1Action.image)}
/> button={isStar1Unlocked}
)} onClick={() => {
if (!isStar1Unlocked) return
navigate(`/${moduleName}/${Star1.starName}/home`)
}}
/>
{/* 名人2 */} {/* 名人2 */}
<Image <Image
className="absolute right-77.5 top-[calc(50%+39px)] -translate-y-1/2" className="absolute right-77.5 top-[calc(50%+39px)] -translate-y-1/2"
src={Star2.image} src={getStarImage(Star2, isStar2Unlocked)}
/>
<Image
className={star2Action.className}
src={getStaticImage(star2Action.image)}
button={isStar2Unlocked}
onClick={() => {
if (!isStar2Unlocked) return
navigate(`/${moduleName}/${Star2.starName}/home`)
}}
/> />
{Star2.image && (
<Image
className="absolute right-90.5 bottom-40"
src={getStaticImage('entrance/点击前往浏览')}
button
onClick={() => navigate(`/${moduleName}/${Star2.starName}/home`)}
/>
)}
{/* <Image {/* <Image
className="absolute right-5 top-1/2 -translate-y-1/2" className="absolute right-5 top-1/2 -translate-y-1/2"
src={Star2.image} src={Star2.image}
+33 -6
View File
@@ -2,6 +2,8 @@ import { useEffect } from 'react'
import { getBackgroundImage, getStaticImage } from '@/lib/utils' import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components' import { Image } from '@/components'
import { usePuzzleStore } from '@/store/puzzleStore'
import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression'
import { getUserDefine } from '@/lib/utils/api-common' import { getUserDefine } from '@/lib/utils/api-common'
import { import {
useHuangyanpeiStore, useHuangyanpeiStore,
@@ -21,6 +23,17 @@ const Huangyanpei = () => {
const lastVisitedPlace = useHuangyanpeiStore((state) => state.lastVisitedPlace) const lastVisitedPlace = useHuangyanpeiStore((state) => state.lastVisitedPlace)
const reflectionRoute = useHuangyanpeiStore((state) => state.reflectionRoute) const reflectionRoute = useHuangyanpeiStore((state) => state.reflectionRoute)
const puzzles = usePuzzleStore((state) => state.puzzles)
const isLoaded = usePuzzleStore((state) => state.isLoaded)
const { dialogueUnlocked, reflectionUnlocked, internalizationUnlocked } =
getResidenceStageUnlocks(starName, puzzles)
useEffect(() => {
if (isLoaded && !isResidenceUnlocked(starName, puzzles)) {
navigate('/menu', { replace: true })
}
}, [isLoaded, navigate, puzzles, starName])
useEffect(() => { useEffect(() => {
;(async () => { ;(async () => {
try { try {
@@ -72,21 +85,35 @@ const Huangyanpei = () => {
<div className="absolute right-20 top-75 flex flex-col space-y-10"> <div className="absolute right-20 top-75 flex flex-col space-y-10">
{/* 对话 */} {/* 对话 */}
<Image <Image
button button={dialogueUnlocked}
className={dialogueUnlocked ? undefined : 'opacity-50 grayscale cursor-not-allowed'}
src={getStaticImage('star/一-解锁')} src={getStaticImage('star/一-解锁')}
onClick={() => navigate(`/${moduleName}/${starName}/${lastVisitedPlace || 'place1'}`)} onClick={() => {
if (!dialogueUnlocked) return
navigate(`/${moduleName}/${starName}/${lastVisitedPlace || 'place1'}`)
}}
/> />
{/* 体悟 */} {/* 体悟 */}
<Image <Image
button button={reflectionUnlocked}
className={reflectionUnlocked ? undefined : 'opacity-50 grayscale cursor-not-allowed'}
src={getStaticImage('star/二-解锁')} src={getStaticImage('star/二-解锁')}
onClick={() => navigate(`/${moduleName}/${starName}/${reflectionRoute || 'reflection'}`)} onClick={() => {
if (!reflectionUnlocked) return
navigate(`/${moduleName}/${starName}/${reflectionRoute || 'reflection'}`)
}}
/> />
{/* 内化 */} {/* 内化 */}
<Image <Image
button button={internalizationUnlocked}
className={
internalizationUnlocked ? undefined : 'opacity-50 grayscale cursor-not-allowed'
}
src={getStaticImage('star/三-解锁')} src={getStaticImage('star/三-解锁')}
onClick={() => navigate(`/${moduleName}/${starName}/internalization`)} onClick={() => {
if (!internalizationUnlocked) return
navigate(`/${moduleName}/${starName}/internalization`)
}}
/> />
</div> </div>
</div> </div>
@@ -163,6 +163,7 @@ const HuangyanpeiReflectionScene2 = () => {
visible={showGuideModal} visible={showGuideModal}
onContinue={() => setShowGuideModal(false)} onContinue={() => setShowGuideModal(false)}
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_24.mp3')} audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_24.mp3')}
firstLineIndent="2em"
> >
欢迎来到浦东中学!{'\n'} 欢迎来到浦东中学!{'\n'}
这是近代上海第一所完全中学,黄炎培先生当首任校长,在这里能学到更多实用知识,还能树立成才的理想哦。 这是近代上海第一所完全中学,黄炎培先生当首任校长,在这里能学到更多实用知识,还能树立成才的理想哦。
+2 -2
View File
@@ -106,7 +106,7 @@ const HuangyanpeiReflectionScene3 = () => {
> >
终于到中华职业学校啦!这里是中国近代职教的摇篮,“免学费、包分配”,还能学真手艺,快来练就一技之长。 终于到中华职业学校啦!这里是中国近代职教的摇篮,“免学费、包分配”,还能学真手艺,快来练就一技之长。
{'\n'} {'\n'}
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;你选了学技术,那就来挑战制作中华职校的“明星产品”——台钳吧!当年这台钳还拿过巴拿马万国博览会金奖呢! 你选了学技术,那就来挑战制作中华职校的“明星产品”——台钳吧!当年这台钳还拿过巴拿马万国博览会金奖呢!
</StudyGuideModal> </StudyGuideModal>
{/* 互动展品光标:组装台钳互动 */} {/* 互动展品光标:组装台钳互动 */}
@@ -173,7 +173,7 @@ const HuangyanpeiReflectionScene3 = () => {
<SuccessAlert <SuccessAlert
visible={showSuccessAlert} visible={showSuccessAlert}
title="满分!" title="满分!"
content='黄先生说“手脑并用”,懂了理论,也要动手实践。' content="黄先生说“手脑并用”,懂了理论,也要动手实践。"
onContinue={() => { onContinue={() => {
setShowSuccessAlert(false) setShowSuccessAlert(false)
markReflectionCursorVisited(scene, 2) markReflectionCursorVisited(scene, 2)
+1 -1
View File
@@ -209,7 +209,7 @@ const HuangyanpeiReflectionScene4 = () => {
> >
终于到中华职业学校啦!这里是中国近代职教的摇篮,“免学费、包分配”,还能学真手艺,快来练就一技之长。 终于到中华职业学校啦!这里是中国近代职教的摇篮,“免学费、包分配”,还能学真手艺,快来练就一技之长。
{'\n'} {'\n'}
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;现在要完成你的第一个任务:推销中华职校生产的“国货精品”——手工纽扣和纯棉毛巾!当年学生们就是带着这些国货,走街串巷做推销呢! 现在要完成你的第一个任务:推销中华职校生产的“国货精品”——手工纽扣和纯棉毛巾!当年学生们就是带着这些国货,走街串巷做推销呢!
</StudyGuideModal> </StudyGuideModal>
{showVideo && ( {showVideo && (
+33 -6
View File
@@ -2,6 +2,8 @@ import { useEffect } from 'react'
import { getBackgroundImage, getStaticImage } from '@/lib/utils' import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components' import { Image } from '@/components'
import { usePuzzleStore } from '@/store/puzzleStore'
import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression'
import { getUserDefine } from '@/lib/utils/api-common' import { getUserDefine } from '@/lib/utils/api-common'
import { useLuxunStore, LUXUN_LESSON_ID, LUXUN_KEY } from '@/store/luxunStore' import { useLuxunStore, LUXUN_LESSON_ID, LUXUN_KEY } from '@/store/luxunStore'
@@ -28,6 +30,17 @@ const Luxun = () => {
const lastVisitedPlace = useLuxunStore((state) => state.lastVisitedPlace) const lastVisitedPlace = useLuxunStore((state) => state.lastVisitedPlace)
const reflectionRoute = useLuxunStore((state) => state.reflectionRoute) const reflectionRoute = useLuxunStore((state) => state.reflectionRoute)
const puzzles = usePuzzleStore((state) => state.puzzles)
const isLoaded = usePuzzleStore((state) => state.isLoaded)
const { dialogueUnlocked, reflectionUnlocked, internalizationUnlocked } =
getResidenceStageUnlocks(starName, puzzles)
useEffect(() => {
if (isLoaded && !isResidenceUnlocked(starName, puzzles)) {
navigate('/menu', { replace: true })
}
}, [isLoaded, navigate, puzzles, starName])
useEffect(() => { useEffect(() => {
;(async () => { ;(async () => {
try { try {
@@ -78,21 +91,35 @@ const Luxun = () => {
<div className="absolute right-20 top-75 flex flex-col space-y-10"> <div className="absolute right-20 top-75 flex flex-col space-y-10">
{/* 对话 */} {/* 对话 */}
<Image <Image
button button={dialogueUnlocked}
className={dialogueUnlocked ? undefined : 'opacity-50 grayscale cursor-not-allowed'}
src={getStaticImage('star/一-解锁')} src={getStaticImage('star/一-解锁')}
onClick={() => navigate(`/${moduleName}/${starName}/${lastVisitedPlace || 'place1'}`)} onClick={() => {
if (!dialogueUnlocked) return
navigate(`/${moduleName}/${starName}/${lastVisitedPlace || 'place1'}`)
}}
/> />
{/* 体悟 */} {/* 体悟 */}
<Image <Image
button button={reflectionUnlocked}
className={reflectionUnlocked ? undefined : 'opacity-50 grayscale cursor-not-allowed'}
src={getStaticImage('star/二-解锁')} src={getStaticImage('star/二-解锁')}
onClick={() => navigate(`/${moduleName}/${starName}/${reflectionRoute || 'reflection'}`)} onClick={() => {
if (!reflectionUnlocked) return
navigate(`/${moduleName}/${starName}/${reflectionRoute || 'reflection'}`)
}}
/> />
{/* 内化 */} {/* 内化 */}
<Image <Image
button button={internalizationUnlocked}
className={
internalizationUnlocked ? undefined : 'opacity-50 grayscale cursor-not-allowed'
}
src={getStaticImage('star/三-解锁')} src={getStaticImage('star/三-解锁')}
onClick={() => navigate(`/${moduleName}/${starName}/internalization`)} onClick={() => {
if (!internalizationUnlocked) return
navigate(`/${moduleName}/${starName}/internalization`)
}}
/> />
</div> </div>
</div> </div>
+3 -2
View File
@@ -235,6 +235,7 @@ const LuxunPlace1 = () => {
visible={showAfterDialogGuide} visible={showAfterDialogGuide}
onContinue={() => setShowAfterDialogGuide(false)} onContinue={() => setShowAfterDialogGuide(false)}
audioSrc={getPublicPath('audio/luxun/mp3_luxun_01.mp3')} audioSrc={getPublicPath('audio/luxun/mp3_luxun_01.mp3')}
firstLineIndent="2em"
> >
{`同学你好,欢迎来到“走进故居·对话”学习模块! {`同学你好,欢迎来到“走进故居·对话”学习模块!
此刻你正立在鲁迅故居的一楼客厅里,接下来请随旧时光的纹路,一同翻开先生思想长卷里的5段深刻轨迹:风雨前夕的思想淬炼、杂文集里的匕首与投枪、历史新编中的现实呐喊、为亡友编纂的精神丰碑、6平方米的红色庇护所。准备好共赴这场笔尖与灵魂的滚烫相遇了吗?`} 此刻你正立在鲁迅故居的一楼客厅里,接下来请随旧时光的纹路,一同翻开先生思想长卷里的5段深刻轨迹:风雨前夕的思想淬炼、杂文集里的匕首与投枪、历史新编中的现实呐喊、为亡友编纂的精神丰碑、6平方米的红色庇护所。准备好共赴这场笔尖与灵魂的滚烫相遇了吗?`}
@@ -279,10 +280,10 @@ const LuxunPlace1 = () => {
{ id: '4', label: '1927年3-9月' }, { id: '4', label: '1927年3-9月' },
]} ]}
rightItems={[ rightItems={[
{ id: 'a', label: '在绍兴三味书屋求学积淀' },
{ id: 'b', label: '于北京八道湾参加新文化运动、酝酿《阿Q正传》' }, { id: 'b', label: '于北京八道湾参加新文化运动、酝酿《阿Q正传》' },
{ id: 'c', label: '赴厦门大学任教,聚焦底层创作杂文' }, { id: 'a', label: '在绍兴三味书屋求学积淀' },
{ id: 'd', label: '在广州白云楼整理旧作定稿《朝花夕拾》部分篇章' }, { id: 'd', label: '在广州白云楼整理旧作定稿《朝花夕拾》部分篇章' },
{ id: 'c', label: '赴厦门大学任教,聚焦底层创作杂文' },
]} ]}
correctMatches={[ correctMatches={[
{ leftId: '1', rightId: 'a' }, { leftId: '1', rightId: 'a' },
+1 -1
View File
@@ -112,7 +112,7 @@ const LuxunPlace5 = () => {
<> <>
<SingleChoiceQuizPanel <SingleChoiceQuizPanel
visible={!activeVideoSuccess} visible={!activeVideoSuccess}
title='【单选】毛泽东主席称赞鲁迅先生为“最伟大和最英勇的___”。' title="【单选】毛泽东主席称赞鲁迅先生为“最伟大和最英勇的___”。"
options={[ options={[
{ value: 'A', label: 'A. 旗手' }, { value: 'A', label: 'A. 旗手' },
{ value: 'B', label: 'B. 作家' }, { value: 'B', label: 'B. 作家' },
+1 -2
View File
@@ -328,8 +328,7 @@ const LuxunReflectionScene1 = () => {
setShowGuideModal(false) setShowGuideModal(false)
}} }}
audioSrc={getPublicPath('audio/luxun/mp3_luxun_31.mp3')} audioSrc={getPublicPath('audio/luxun/mp3_luxun_31.mp3')}
contentPaddingLeft="2em" firstLineIndent="2em"
// firstLineIndent="2em"
> >
{`同学你好,欢迎来到“跨越时空·体悟”学习模块! {`同学你好,欢迎来到“跨越时空·体悟”学习模块!
请登陆“时空回溯・亲历时代”体验仓,下一站:1892年,绍兴三味书屋。 请登陆“时空回溯・亲历时代”体验仓,下一站:1892年,绍兴三味书屋。
+34 -6
View File
@@ -1,6 +1,9 @@
import { useEffect } from 'react'
import { getBackgroundImage, getStaticImage } from '@/lib/utils' import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components' import { Image } from '@/components'
import { usePuzzleStore } from '@/store/puzzleStore'
import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression'
import { useMaozedongStore } from '@/store/maozedongStore' import { useMaozedongStore } from '@/store/maozedongStore'
const Maozedong = () => { const Maozedong = () => {
@@ -13,6 +16,17 @@ const Maozedong = () => {
const starName = 'maozedong' const starName = 'maozedong'
const lastVisitedPlace = useMaozedongStore((state) => state.lastVisitedPlace) const lastVisitedPlace = useMaozedongStore((state) => state.lastVisitedPlace)
const puzzles = usePuzzleStore((state) => state.puzzles)
const isLoaded = usePuzzleStore((state) => state.isLoaded)
const { dialogueUnlocked, reflectionUnlocked, internalizationUnlocked } =
getResidenceStageUnlocks(starName, puzzles)
useEffect(() => {
if (isLoaded && !isResidenceUnlocked(starName, puzzles)) {
navigate('/menu', { replace: true })
}
}, [isLoaded, navigate, puzzles, starName])
if (!moduleName) return null if (!moduleName) return null
return ( return (
@@ -31,19 +45,33 @@ const Maozedong = () => {
<div className="absolute right-20 top-75 flex flex-col space-y-10"> <div className="absolute right-20 top-75 flex flex-col space-y-10">
<Image <Image
button button={dialogueUnlocked}
className={dialogueUnlocked ? undefined : 'opacity-50 grayscale cursor-not-allowed'}
src={getStaticImage('star/一-解锁')} src={getStaticImage('star/一-解锁')}
onClick={() => navigate(`/${moduleName}/${starName}/${lastVisitedPlace || 'place1'}`)} onClick={() => {
if (!dialogueUnlocked) return
navigate(`/${moduleName}/${starName}/${lastVisitedPlace || 'place1'}`)
}}
/> />
<Image <Image
button button={reflectionUnlocked}
className={reflectionUnlocked ? undefined : 'opacity-50 grayscale cursor-not-allowed'}
src={getStaticImage('star/二-解锁')} src={getStaticImage('star/二-解锁')}
onClick={() => navigate(`/${moduleName}/${starName}/reflection`)} onClick={() => {
if (!reflectionUnlocked) return
navigate(`/${moduleName}/${starName}/reflection`)
}}
/> />
<Image <Image
button button={internalizationUnlocked}
className={
internalizationUnlocked ? undefined : 'opacity-50 grayscale cursor-not-allowed'
}
src={getStaticImage('star/三-解锁')} src={getStaticImage('star/三-解锁')}
onClick={() => navigate(`/${moduleName}/${starName}/internalization`)} onClick={() => {
if (!internalizationUnlocked) return
navigate(`/${moduleName}/${starName}/internalization`)
}}
/> />
</div> </div>
</div> </div>
+2 -1
View File
@@ -386,6 +386,7 @@ const MaozedongInternalization = () => {
{/* 过渡语 */} {/* 过渡语 */}
<Narration <Narration
visible={showTransition} visible={showTransition}
audioSrc={getPublicPath('audio/maozedong/mp3_mzd_27.mp3')}
onClose={() => { onClose={() => {
setShowTransition(false) setShowTransition(false)
setIntroDialogVisible(true) setIntroDialogVisible(true)
@@ -464,7 +465,7 @@ const MaozedongInternalization = () => {
speaker="student" speaker="student"
audioSrc={getPublicPath('audio/maozedong/mp3_mzd_24.mp3')} audioSrc={getPublicPath('audio/maozedong/mp3_mzd_24.mp3')}
> >
关于“实事求是、群众路线、独立自主”这十二个字我还是有些不理解,总感觉这十二个字离我们很遥远。 小沪,关于“实事求是、群众路线、独立自主”这十二个字我还是有些不理解,总感觉这十二个字离我们很遥远。
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/maozedong/mp3_mzd_25.mp3')}> <GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/maozedong/mp3_mzd_25.mp3')}>
“实事求是、群众路线、独立自主”这十二个字,可以说是中国共产党人认识世界、改造世界的根本方法与价值遵循。今天我们有幸邀请到了华东师范大学马克思主义学院马立政副教授。关于你的问题,我想马教授会给你指点迷津! “实事求是、群众路线、独立自主”这十二个字,可以说是中国共产党人认识世界、改造世界的根本方法与价值遵循。今天我们有幸邀请到了华东师范大学马克思主义学院马立政副教授。关于你的问题,我想马教授会给你指点迷津!
+25 -21
View File
@@ -10,7 +10,7 @@ import { SuccessAlert } from '@/components/layout'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext' import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { cn, getStaticImage } from '@/lib/utils' import { cn, getStaticImage } from '@/lib/utils'
import { getPublicPath } from '@/lib/utils' import { getPublicPath } from '@/lib/utils'
import { useMemo, useRef, useState } from 'react' import { useCallback, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
// 光标组件 - 带旋转动画 // 光标组件 - 带旋转动画
@@ -222,17 +222,36 @@ const Home = () => {
const bgmRef = useRef<HTMLAudioElement>(null) const bgmRef = useRef<HTMLAudioElement>(null)
const videoRef = useRef<VideoRef>(null) const videoRef = useRef<VideoRef>(null)
const navigate = useNavigate() const navigate = useNavigate()
const [showGuideModal, setShowGuideModal] = useState(true)
const [endingVisible, setEndingVisible] = useState(false) const [endingVisible, setEndingVisible] = useState(false)
const moduleName = 'maozedong' const moduleName = 'maozedong'
const playBGM = () => { const playBGM = useCallback(() => {
bgmRef.current?.play().catch(() => {}) bgmRef.current?.play().catch(() => {})
} }, [])
const anchors: VideoAnchor[] = useMemo( const anchors: VideoAnchor[] = useMemo(
() => [ () => [
{
id: 'guide-modal',
time: 5,
afterResume: 'hide',
render: ({ resume }) => (
<StudyGuideModal
visible
audioSrc={getPublicPath('audio/maozedong/mp3_mzd_22.mp3')}
onContinue={() => {
playBGM()
resume()
}}
firstLineIndent="2em"
>
{`同学你好,欢迎来到“跨越时空·体悟”学习模块!
请登陆“时空回溯・亲历时代”体验仓,下一站:1924年,上海。
1924年的上海,革命浪潮暗涌,中共四大筹备工作关乎革命前途。无产阶级领导权、工农联盟等核心议题亟待明确,毛泽东同志在筹备关键节点面临诸多重要抉择。请你化身历史见证者,站在毛主席的视角做出选择,解锁四大筹备的关键密码,感受革命先辈的坚定与智慧!完成三次选择后,可进入下一阶段的学习。时空门已开,请做好准备迎接考验吧!`}
</StudyGuideModal>
),
},
{ {
id: 'anchor-0', id: 'anchor-0',
time: 3, time: 3,
@@ -461,7 +480,7 @@ const Home = () => {
render: ({ resume, unmute }) => <EndingQuizCursor resume={resume} unmute={unmute} />, render: ({ resume, unmute }) => <EndingQuizCursor resume={resume} unmute={unmute} />,
}, },
], ],
[triggerPuzzleAnimation] [playBGM, triggerPuzzleAnimation]
) )
return ( return (
@@ -472,7 +491,7 @@ const Home = () => {
src={getPublicPath('video/maozedong/mzd_7_8_9_tiwu.mp4')} src={getPublicPath('video/maozedong/mzd_7_8_9_tiwu.mp4')}
className="w-full h-full aspect-video" className="w-full h-full aspect-video"
anchors={anchors} anchors={anchors}
autoPlay={false} autoPlay
muted muted
playsInline playsInline
onEnded={() => { onEnded={() => {
@@ -480,21 +499,6 @@ const Home = () => {
}} }}
/> />
<StudyGuideModal
visible={showGuideModal}
audioSrc={getPublicPath('audio/maozedong/mp3_mzd_22.mp3')}
onContinue={() => {
setShowGuideModal(false)
playBGM()
videoRef.current?.play()
}}
firstLineIndent="2em"
>
{`同学你好,欢迎来到“跨越时空·体悟”学习模块!
请登陆“时空回溯・亲历时代”体验仓,下一站:1924年,上海。
1924年的上海,革命浪潮暗涌,中共四大筹备工作关乎革命前途。无产阶级领导权、工农联盟等核心议题亟待明确,毛泽东同志在筹备关键节点面临诸多重要抉择。请你化身历史见证者,站在毛主席的视角做出选择,解锁四大筹备的关键密码,感受革命先辈的坚定与智慧!完成三次选择后,可进入下一阶段的学习。时空门已开,请做好准备迎接考验吧!`}
</StudyGuideModal>
<EndingOverlay <EndingOverlay
visible={endingVisible} visible={endingVisible}
audioSrc={getPublicPath('audio/maozedong/mp3_mzd_23.mp3')} audioSrc={getPublicPath('audio/maozedong/mp3_mzd_23.mp3')}
+34 -6
View File
@@ -1,6 +1,9 @@
import { useEffect } from 'react'
import { getBackgroundImage, getStaticImage } from '@/lib/utils' import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components' import { Image } from '@/components'
import { usePuzzleStore } from '@/store/puzzleStore'
import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression'
import { useNieerStore } from '@/store/nieerStore' import { useNieerStore } from '@/store/nieerStore'
const NieerHome = () => { const NieerHome = () => {
@@ -11,6 +14,17 @@ const NieerHome = () => {
const starName = 'nieer' const starName = 'nieer'
const lastVisitedPlace = useNieerStore((s) => s.lastVisitedPlace) const lastVisitedPlace = useNieerStore((s) => s.lastVisitedPlace)
const puzzles = usePuzzleStore((state) => state.puzzles)
const isLoaded = usePuzzleStore((state) => state.isLoaded)
const { dialogueUnlocked, reflectionUnlocked, internalizationUnlocked } =
getResidenceStageUnlocks(starName, puzzles)
useEffect(() => {
if (isLoaded && !isResidenceUnlocked(starName, puzzles)) {
navigate('/menu', { replace: true })
}
}, [isLoaded, navigate, puzzles, starName])
if (!moduleName) return null if (!moduleName) return null
return ( return (
@@ -30,20 +44,34 @@ const NieerHome = () => {
<div className="absolute right-20 top-75 flex flex-col space-y-10"> <div className="absolute right-20 top-75 flex flex-col space-y-10">
{/* 对话 */} {/* 对话 */}
<Image <Image
button button={dialogueUnlocked}
className={dialogueUnlocked ? undefined : 'opacity-50 grayscale cursor-not-allowed'}
src={getStaticImage('star/一-解锁')} src={getStaticImage('star/一-解锁')}
onClick={() => navigate(`/${moduleName}/${starName}/${lastVisitedPlace || 'place1'}`)} onClick={() => {
if (!dialogueUnlocked) return
navigate(`/${moduleName}/${starName}/${lastVisitedPlace || 'place1'}`)
}}
/> />
{/* 体悟 / 内化 预留 */} {/* 体悟 / 内化 预留 */}
<Image <Image
button button={reflectionUnlocked}
className={reflectionUnlocked ? undefined : 'opacity-50 grayscale cursor-not-allowed'}
src={getStaticImage('star/二-解锁')} src={getStaticImage('star/二-解锁')}
onClick={() => navigate(`/${moduleName}/${starName}/reflection`)} onClick={() => {
if (!reflectionUnlocked) return
navigate(`/${moduleName}/${starName}/reflection`)
}}
/> />
<Image <Image
button button={internalizationUnlocked}
className={
internalizationUnlocked ? undefined : 'opacity-50 grayscale cursor-not-allowed'
}
src={getStaticImage('star/三-解锁')} src={getStaticImage('star/三-解锁')}
onClick={() => navigate(`/${moduleName}/${starName}/internalization`)} onClick={() => {
if (!internalizationUnlocked) return
navigate(`/${moduleName}/${starName}/internalization`)
}}
/> />
</div> </div>
</div> </div>
+20 -9
View File
@@ -333,17 +333,28 @@ const NieerInternalization = () => {
/> />
)} )}
<Narration <GuideSteps
visible={showPostNarration} visible={showPostNarration}
audioChannelId="nieer-internalization-narration-post" onVisibleChange={(visible) => {
audioSrc={getPublicPath('audio/nieer/mp3_nieer_39.mp3')} setShowPostNarration(visible)
animationSpeed={200} if (!visible) {
onClose={() => { setShowLetter(true)
setShowPostNarration(false) }
setShowLetter(true)
}} }}
text="的确如戏!这份精神,不仅是音乐创作的指引,更是广大青年学子成长路上的精神灯塔,指引大家在专业学习中精益求精,在青春岁月里奋勇前行。此刻,我们仿佛依然站在聂耳旧居的亭子间里,仿佛还能看到先生熬夜打磨旋律的身影,感受到他心中滚烫的家国情怀。现在,就请你静下心来,把对聂耳的崇敬、对国歌精神的体悟和对自己未来的期许,一字一句写成一封信,完成与先生跨越时空的对话吧!" showMask={false}
/> zIndex={60}
audioChannelId="nieer-internalization-narration-post"
>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_39.mp3')}>
的确如戏!这份精神,不仅是音乐创作的指引,更是广大青年学子成长路上的精神灯塔,指引大家在专业学习中精益求精,在青春岁月里奋勇前行。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
此刻,我们仿佛依然站在聂耳旧居的亭子间里,仿佛还能看到先生熬夜打磨旋律的身影,感受到他心中滚烫的家国情怀。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
现在,就请你静下心来,把对聂耳的崇敬、对国歌精神的体悟和对自己未来的期许,一字一句写成一封信,完成与先生跨越时空的对话吧!
</GuideStepsItem>
</GuideSteps>
{/* 写信组件 */} {/* 写信组件 */}
<LetterToNieer <LetterToNieer
+2 -1
View File
@@ -315,7 +315,7 @@ const NieerPlace1 = () => {
ref={video2Ref} ref={video2Ref}
className="fixed inset-0 z-20000" className="fixed inset-0 z-20000"
src={getPublicPath( src={getPublicPath(
video2Step === 1 ? 'video/chenyun/chenyun_01.mp4' : 'video/nieer/nieer_2.mp4' video2Step === 1 ? 'video/nieer/nieer_01.mp4' : 'video/nieer/nieer_2.mp4'
)} )}
autoPlay autoPlay
playsInline playsInline
@@ -404,6 +404,7 @@ const NieerPlace1 = () => {
visible={showAfterDialogGuide} visible={showAfterDialogGuide}
onContinue={() => setShowAfterDialogGuide(false)} onContinue={() => setShowAfterDialogGuide(false)}
audioSrc={getPublicPath('audio/nieer/mp3_nieer_01.mp3')} audioSrc={getPublicPath('audio/nieer/mp3_nieer_01.mp3')}
firstLineIndent="2em"
> >
同学你好,欢迎来到“走进故居·对话”学习模块!{'\n'} 同学你好,欢迎来到“走进故居·对话”学习模块!{'\n'}
此刻你正位立于国歌展示馆的入口,请随时光的脚步,在聂耳作品的“时光声浪”中聆听五段炽热的时代回响:民族危亡的怒吼声、时代呼唤的最强音、民族解放的冲锋号、强我中华的进行曲、伟大复兴的主旋律。你准备好跟随聂耳的音符,踏上这段炽热征程了吗? 此刻你正位立于国歌展示馆的入口,请随时光的脚步,在聂耳作品的“时光声浪”中聆听五段炽热的时代回响:民族危亡的怒吼声、时代呼唤的最强音、民族解放的冲锋号、强我中华的进行曲、伟大复兴的主旋律。你准备好跟随聂耳的音符,踏上这段炽热征程了吗?
+1 -1
View File
@@ -240,7 +240,7 @@ const NieerPlace3 = () => {
src={ src={
videoForExhibitId === 1 videoForExhibitId === 1
? getPublicPath('video/nieer/nieer_5.mp4') ? getPublicPath('video/nieer/nieer_5.mp4')
: getPublicPath('video/chenyun/chenyun_01.mp4') //正确地址nieer_02目前没有,暂时使用其他代替 : getPublicPath('video/nieer/nieer_02.mp4') //正确地址nieer_02目前没有,暂时使用其他代替
} }
autoPlay autoPlay
playsInline playsInline
+28 -4
View File
@@ -1,6 +1,6 @@
import { Image, StudyGuideModal } from '@/components' import { EndingOverlay, Image, StudyGuideModal } from '@/components'
import { cn, getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils' import { cn, getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useMemo } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { LOBBY_COLUMNS, LOBBY_LAYOUT, LOBBY_TIMELINE } from './lobby.config' import { LOBBY_COLUMNS, LOBBY_LAYOUT, LOBBY_TIMELINE } from './lobby.config'
import { useNieerStore } from '@/store/nieerStore' import { useNieerStore } from '@/store/nieerStore'
@@ -50,6 +50,20 @@ const NieerReflection = () => {
[placeCompletion] [placeCompletion]
) )
const [endingVisible, setEndingVisible] = useState(false)
const prevDialogModeRef = useRef(dialogState.mode)
useEffect(() => {
if (
prevDialogModeRef.current === 'result' &&
dialogState.mode === 'idle' &&
allPlacesCompleted
) {
setEndingVisible(true)
}
prevDialogModeRef.current = dialogState.mode
}, [dialogState.mode, allPlacesCompleted])
const handleResultShare = (sceneId: number, payload?: Record<string, unknown>) => { const handleResultShare = (sceneId: number, payload?: Record<string, unknown>) => {
handleShare({ handleShare({
payload, payload,
@@ -244,13 +258,23 @@ const NieerReflection = () => {
visible={showGuideModal} visible={showGuideModal}
onContinue={markReflectionGuideSeen} onContinue={markReflectionGuideSeen}
audioSrc={getPublicPath('audio/nieer/mp3_nieer_28.mp3')} audioSrc={getPublicPath('audio/nieer/mp3_nieer_28.mp3')}
contentPaddingLeft="2em" firstLineIndent="2em"
// firstLineIndent="2em"
> >
{`同学你好,欢迎来到“跨越时空·体悟”学习模块! {`同学你好,欢迎来到“跨越时空·体悟”学习模块!
请登陆“时空回溯・亲历时代”体验仓,下一站:1936年,上海。 请登陆“时空回溯・亲历时代”体验仓,下一站:1936年,上海。
请带上你的旅行包,穿越时空解锁6个国歌故事,集齐精神徽章,绘制属于你的国歌精神图谱吧~时空门已开,请出发吧!`} 请带上你的旅行包,穿越时空解锁6个国歌故事,集齐精神徽章,绘制属于你的国歌精神图谱吧~时空门已开,请出发吧!`}
</StudyGuideModal> </StudyGuideModal>
<EndingOverlay
visible={endingVisible}
audioSrc={getPublicPath('audio/nieer/mp3_nieer_35.mp3')}
animationSpeed={200}
text={`恭喜你完成所有时空旅行!成功绘制“国歌精神图谱”,解锁6个跨越时空的国歌故事~国歌的故事还在继续,它的精神藏在每一次唱响、每一次坚守中。愿你带着这份精神力量,做国歌的传承者、爱国的践行者!`}
onContinue={() => {
setEndingVisible(false)
navigate(`/${moduleName}/${starName}/home`)
}}
/>
</div> </div>
) )
} }
+34 -6
View File
@@ -1,5 +1,8 @@
import { useEffect } from 'react'
import { getBackgroundImage, getStaticImage } from '@/lib/utils' import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { Image } from '@/components' import { Image } from '@/components'
import { usePuzzleStore } from '@/store/puzzleStore'
import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { useRongdeshengStore } from '@/store/rongdeshengStore' import { useRongdeshengStore } from '@/store/rongdeshengStore'
@@ -11,6 +14,17 @@ const RongdeshengHome = () => {
const starName = 'rongdesheng' const starName = 'rongdesheng'
const lastVisitedPlace = useRongdeshengStore((s) => s.lastVisitedPlace) const lastVisitedPlace = useRongdeshengStore((s) => s.lastVisitedPlace)
const puzzles = usePuzzleStore((state) => state.puzzles)
const isLoaded = usePuzzleStore((state) => state.isLoaded)
const { dialogueUnlocked, reflectionUnlocked, internalizationUnlocked } =
getResidenceStageUnlocks(starName, puzzles)
useEffect(() => {
if (isLoaded && !isResidenceUnlocked(starName, puzzles)) {
navigate('/menu', { replace: true })
}
}, [isLoaded, navigate, puzzles, starName])
if (!moduleName) return null if (!moduleName) return null
return ( return (
@@ -30,23 +44,37 @@ const RongdeshengHome = () => {
<div className="absolute right-20 top-75 flex flex-col space-y-10"> <div className="absolute right-20 top-75 flex flex-col space-y-10">
{/* 对话 */} {/* 对话 */}
<Image <Image
button button={dialogueUnlocked}
className={dialogueUnlocked ? undefined : 'opacity-50 grayscale cursor-not-allowed'}
src={getStaticImage('star/一-解锁')} src={getStaticImage('star/一-解锁')}
onClick={() => navigate(`/${moduleName}/${starName}/${lastVisitedPlace || 'place1'}`)} onClick={() => {
if (!dialogueUnlocked) return
navigate(`/${moduleName}/${starName}/${lastVisitedPlace || 'place1'}`)
}}
/> />
{/* 体悟(占位) */} {/* 体悟(占位) */}
<Image <Image
button button={reflectionUnlocked}
className={reflectionUnlocked ? undefined : 'opacity-50 grayscale cursor-not-allowed'}
src={getStaticImage('star/二-解锁')} src={getStaticImage('star/二-解锁')}
onClick={() => navigate(`/${moduleName}/${starName}/reflection`)} onClick={() => {
if (!reflectionUnlocked) return
navigate(`/${moduleName}/${starName}/reflection`)
}}
/> />
{/* 内化(占位) */} {/* 内化(占位) */}
<Image <Image
button button={internalizationUnlocked}
className={
internalizationUnlocked ? undefined : 'opacity-50 grayscale cursor-not-allowed'
}
src={getStaticImage('star/三-解锁')} src={getStaticImage('star/三-解锁')}
onClick={() => navigate(`/${moduleName}/${starName}/internalization`)} onClick={() => {
if (!internalizationUnlocked) return
navigate(`/${moduleName}/${starName}/internalization`)
}}
/> />
</div> </div>
</div> </div>
+1
View File
@@ -272,6 +272,7 @@ const RongdeshengPlace1 = () => {
visible={showAfterDialogGuide} visible={showAfterDialogGuide}
onContinue={() => setShowAfterDialogGuide(false)} onContinue={() => setShowAfterDialogGuide(false)}
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_01.mp3')} audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_01.mp3')}
firstLineIndent="2em"
> >
{` 同学你好,欢迎来到“走进故居·对话”学习模块! {` 同学你好,欢迎来到“走进故居·对话”学习模块!
此刻你正伫立在荣德生旧居的石库门廊下,接下来请循着实业的足迹,一同探寻先生传奇人生里的4段壮阔征程:金融启蒙·资本筑基、实业拓疆·双业兴邦、山河危难·气节如钢、风雨坚守·初心不改。准备好共赴这场面粉与棉纱交织的民族工业史诗了吗?`} 此刻你正伫立在荣德生旧居的石库门廊下,接下来请循着实业的足迹,一同探寻先生传奇人生里的4段壮阔征程:金融启蒙·资本筑基、实业拓疆·双业兴邦、山河危难·气节如钢、风雨坚守·初心不改。准备好共赴这场面粉与棉纱交织的民族工业史诗了吗?`}
+2 -2
View File
@@ -222,7 +222,7 @@ const RongdeshengPlace2 = () => {
)} )}
{showVideo && videoForExhibitId === 2 && ( {showVideo && videoForExhibitId === 2 && (
<Video <Video
src={getPublicPath('video/rongdesheng/rongdesheng_3.mp4')} src={getPublicPath('video/rongdesheng/rongdesheng_4.mp4')}
className="fixed inset-0 z-20000" className="fixed inset-0 z-20000"
autoPlay autoPlay
playsInline playsInline
@@ -233,7 +233,7 @@ const RongdeshengPlace2 = () => {
)} )}
{showVideo && videoForExhibitId === 4 && ( {showVideo && videoForExhibitId === 4 && (
<Video <Video
src={getPublicPath('video/rongdesheng/rongdesheng_3.mp4')} src={getPublicPath('video/rongdesheng/rongdesheng_5.mp4')}
className="fixed inset-0 z-20000" className="fixed inset-0 z-20000"
autoPlay autoPlay
playsInline playsInline
+19 -1
View File
@@ -48,6 +48,7 @@ const RongdeshengPlace4 = () => {
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | 4 | null>(null) const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | 4 | null>(null)
const [showVideo, setShowVideo] = useState(false) const [showVideo, setShowVideo] = useState(false)
const [showEndingVideo, setShowEndingVideo] = useState(false)
const [guide1Visible, setGuide1Visible] = useState(false) const [guide1Visible, setGuide1Visible] = useState(false)
const [guide1Step, setGuide1Step] = useState(0) const [guide1Step, setGuide1Step] = useState(0)
const [guide1AfterVideoVisible, setGuide1AfterVideoVisible] = useState(false) const [guide1AfterVideoVisible, setGuide1AfterVideoVisible] = useState(false)
@@ -95,6 +96,11 @@ const RongdeshengPlace4 = () => {
setGuide1AfterVideoVisible(true) setGuide1AfterVideoVisible(true)
} }
const handleEndingVideoEnded = () => {
setShowEndingVideo(false)
setEndingGuideVisible(true)
}
return ( return (
<div <div
className="w-full h-full bg-cover bg-center bg-no-repeat" className="w-full h-full bg-cover bg-center bg-no-repeat"
@@ -174,7 +180,7 @@ const RongdeshengPlace4 = () => {
}) })
if (closingId === 4) { if (closingId === 4) {
setEndingGuideVisible(true) setShowEndingVideo(true)
} }
} }
@@ -196,6 +202,18 @@ const RongdeshengPlace4 = () => {
/> />
)} )}
{showEndingVideo && (
<Video
src={getPublicPath('video/rongdesheng/rongdesheng_11.mp4')}
className="fixed inset-0 z-20000"
autoPlay
playsInline
showSkipButton
onEnded={handleEndingVideoEnded}
onSkip={handleEndingVideoEnded}
/>
)}
<GuideSteps <GuideSteps
visible={guide1Visible} visible={guide1Visible}
onVisibleChange={setGuide1Visible} onVisibleChange={setGuide1Visible}
+1 -1
View File
@@ -350,8 +350,8 @@ const RongdeshengReflection = () => {
setShowGuide(false) setShowGuide(false)
markReflectionGuideShown() markReflectionGuideShown()
}} }}
firstLineIndent="2em"
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_31.mp3')} audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_31.mp3')}
firstLineIndent="2em"
> >
同学你好,欢迎来到“跨越时空·体悟”学习模块!{'\n'} 同学你好,欢迎来到“跨越时空·体悟”学习模块!{'\n'}
请登陆“时空回溯・亲历时代”体验仓,下一站:1915年,上海。{'\n'} 请登陆“时空回溯・亲历时代”体验仓,下一站:1915年,上海。{'\n'}
@@ -171,27 +171,31 @@ export const MODULES: Module[] = [
id: 'opt5-1', id: 'opt5-1',
text: 'A.仿制外国先进设备,改良生产工艺', text: 'A.仿制外国先进设备,改良生产工艺',
isCorrect: false, isCorrect: false,
videoResourceId: 'video5-1',
}, },
{ {
id: 'opt5-2', id: 'opt5-2',
text: 'B.设立职员养成所,培养本土技术人才', text: 'B.设立职员养成所,培养本土技术人才',
isCorrect: true, isCorrect: true,
videoResourceId: 'video5-2', videoResourceId: 'video5-1',
},
{
id: 'opt5-3',
text: 'C.降价促销,以低价抢占市场',
videoResourceId: 'video5-2',
isCorrect: true,
}, },
{ id: 'opt5-3', text: 'C.降价促销,以低价抢占市场', isCorrect: true },
], ],
videoResources: [ videoResources: [
{ {
id: 'video5-1', id: 'video5-1',
thumbnail: 'rongdesheng/reflection/视频5-1', thumbnail: 'rongdesheng/reflection/视频5-1',
videoSrc: '/video/rongdesheng/rongdesheng_2_9.mp4', videoSrc: '/video/rongdesheng/rongdesheng_2_10.mp4',
position: { top: '25%', left: '42%' }, position: { top: '25%', left: '42%' },
}, },
{ {
id: 'video5-2', id: 'video5-2',
thumbnail: 'rongdesheng/reflection/视频5-2', thumbnail: 'rongdesheng/reflection/视频5-2',
videoSrc: '/video/rongdesheng/rongdesheng_2_10.mp4', videoSrc: '/video/rongdesheng/rongdesheng_2_9.mp4',
position: { bottom: '50%', left: '55%' }, position: { bottom: '50%', left: '55%' },
flipY: true, flipY: true,
}, },
+33 -6
View File
@@ -2,6 +2,8 @@ import { useEffect } from 'react'
import { getBackgroundImage, getStaticImage } from '@/lib/utils' import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components' import { Image } from '@/components'
import { usePuzzleStore } from '@/store/puzzleStore'
import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression'
import { getUserDefine } from '@/lib/utils/api-common' import { getUserDefine } from '@/lib/utils/api-common'
import { import {
useSunzhongshanStore, useSunzhongshanStore,
@@ -32,6 +34,17 @@ const Sunzhongshan = () => {
const lastVisitedPlace = useSunzhongshanStore((state) => state.lastVisitedPlace) const lastVisitedPlace = useSunzhongshanStore((state) => state.lastVisitedPlace)
const reflectionRoute = useSunzhongshanStore((state) => state.reflectionRoute) const reflectionRoute = useSunzhongshanStore((state) => state.reflectionRoute)
const puzzles = usePuzzleStore((state) => state.puzzles)
const isLoaded = usePuzzleStore((state) => state.isLoaded)
const { dialogueUnlocked, reflectionUnlocked, internalizationUnlocked } =
getResidenceStageUnlocks(starName, puzzles)
useEffect(() => {
if (isLoaded && !isResidenceUnlocked(starName, puzzles)) {
navigate('/menu', { replace: true })
}
}, [isLoaded, navigate, puzzles, starName])
useEffect(() => { useEffect(() => {
;(async () => { ;(async () => {
try { try {
@@ -84,21 +97,35 @@ const Sunzhongshan = () => {
<div className="absolute right-20 top-75 flex flex-col space-y-10"> <div className="absolute right-20 top-75 flex flex-col space-y-10">
{/* 对话 */} {/* 对话 */}
<Image <Image
button button={dialogueUnlocked}
className={dialogueUnlocked ? undefined : 'opacity-50 grayscale cursor-not-allowed'}
src={getStaticImage('star/一-解锁')} src={getStaticImage('star/一-解锁')}
onClick={() => navigate(`/${moduleName}/${starName}/${lastVisitedPlace || 'place1'}`)} onClick={() => {
if (!dialogueUnlocked) return
navigate(`/${moduleName}/${starName}/${lastVisitedPlace || 'place1'}`)
}}
/> />
{/* 体悟 */} {/* 体悟 */}
<Image <Image
button button={reflectionUnlocked}
className={reflectionUnlocked ? undefined : 'opacity-50 grayscale cursor-not-allowed'}
src={getStaticImage('star/二-解锁')} src={getStaticImage('star/二-解锁')}
onClick={() => navigate(`/${moduleName}/${starName}/${reflectionRoute || 'reflection'}`)} onClick={() => {
if (!reflectionUnlocked) return
navigate(`/${moduleName}/${starName}/${reflectionRoute || 'reflection'}`)
}}
/> />
{/* 内化 */} {/* 内化 */}
<Image <Image
button button={internalizationUnlocked}
className={
internalizationUnlocked ? undefined : 'opacity-50 grayscale cursor-not-allowed'
}
src={getStaticImage('star/三-解锁')} src={getStaticImage('star/三-解锁')}
onClick={() => navigate(`/${moduleName}/${starName}/internalization`)} onClick={() => {
if (!internalizationUnlocked) return
navigate(`/${moduleName}/${starName}/internalization`)
}}
/> />
</div> </div>
</div> </div>
@@ -480,6 +480,7 @@ const SunzhongshanInternalization = () => {
audioChannelId="sunzhongshan-internalization-narration" audioChannelId="sunzhongshan-internalization-narration"
text="从翠亨村的农家院落到影响中国的革命征程,孙中山先生以'亟拯斯民于水火,切扶大厦之将倾'的担当,在近代中国的黑暗中点燃了民主共和的火种。他留下的不仅是推翻帝制、建立共和的历史功绩,更有'振兴中华'的初心、'天下为公'的情怀、'与时俱进'的智慧。今天,我们站在故居的青砖黛瓦间,不仅要回望他为民族独立、人民幸福奋斗的一生,更要让这份精神穿越时空,成为新时代青年砥砺前行的力量。" text="从翠亨村的农家院落到影响中国的革命征程,孙中山先生以'亟拯斯民于水火,切扶大厦之将倾'的担当,在近代中国的黑暗中点燃了民主共和的火种。他留下的不仅是推翻帝制、建立共和的历史功绩,更有'振兴中华'的初心、'天下为公'的情怀、'与时俱进'的智慧。今天,我们站在故居的青砖黛瓦间,不仅要回望他为民族独立、人民幸福奋斗的一生,更要让这份精神穿越时空,成为新时代青年砥砺前行的力量。"
audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_55.mp3')} audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_55.mp3')}
animationSpeed={200}
onClose={() => { onClose={() => {
setShowNarration(false) setShowNarration(false)
}} }}
+303 -318
View File
@@ -14,16 +14,27 @@ import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useState, useRef } from 'react' import { useState, useRef } from 'react'
import Cursor from '@/components/cursor' import Cursor from '@/components/cursor'
import { SuccessAlert, DragQuizPanel } from '@/components/layout' import { SuccessAlert, DragQuizPanel } from '@/components/layout'
import { MultipleChoiceQuizPanel } from '@/components/layout/quiz' import { MultipleChoiceQuizPanel, SelectFillQuizPanel } from '@/components/layout/quiz'
import { useCursorController } from '@/hooks/useCursorController' import { useCursorController } from '@/hooks/useCursorController'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext' import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { useSunzhongshanStore, saveSunzhongshanStore } from '@/store/sunzhongshanStore' import { useSunzhongshanStore, saveSunzhongshanStore } from '@/store/sunzhongshanStore'
// starName 固定为 sunzhongshan // ============================================================================
const starName = 'sunzhongshan' // 常量配置
// ============================================================================
// 展品 ID 与资源路径映射,ID 用来做唯一标识 // 当前角色与场景标识(路由及 store 都需要使用)
const exhibitSrcMap: Record<1 | 2 | 3 | 4 | 5, string> = { const starName = 'sunzhongshan'
const place = 'place1'
// 展品总数(用于进度展示与"完成"判定)
const TOTAL_EXHIBITS = 5
// 展品 ID 类型:本场景包含 5 个可点击展品
type ExhibitId = 1 | 2 | 3 | 4 | 5
// 展品 ID -> 浮窗中显示的资源路径
const exhibitSrcMap: Record<ExhibitId, string> = {
1: 'sunzhongshan/place1/“南北议和”纪念章', 1: 'sunzhongshan/place1/“南北议和”纪念章',
2: 'sunzhongshan/place1/“四大寇”合影', 2: 'sunzhongshan/place1/“四大寇”合影',
3: 'sunzhongshan/place1/民立报', 3: 'sunzhongshan/place1/民立报',
@@ -31,37 +42,67 @@ const exhibitSrcMap: Record<1 | 2 | 3 | 4 | 5, string> = {
5: 'sunzhongshan/place1/郑观应的推荐信', 5: 'sunzhongshan/place1/郑观应的推荐信',
} }
// 展品 ID -> 拼图碎片动画索引(展品 1 学习完成后通过答题触发,故无掉落动画)
const exhibitPuzzleMap: Partial<Record<ExhibitId, number>> = {
2: 1,
3: 4,
4: 3,
5: 2,
}
// 光标位置配置:用数组驱动渲染,避免 5 个 <Cursor> 重复样板代码
const cursorList: Array<{ id: ExhibitId; className: string }> = [
{ id: 1, className: 'absolute left-75 bottom-80' },
{ id: 2, className: 'absolute left-108 bottom-118' },
{ id: 3, className: 'absolute left-165 bottom-90' },
{ id: 4, className: 'absolute right-192 bottom-118' },
{ id: 5, className: 'absolute right-188 bottom-88' },
]
// 音频路径助手:避免反复书写完整前缀
const audio = (n: string) => getPublicPath(`audio/sunzhongshan/mp3_sunzhongshan_${n}.mp3`)
// ============================================================================
// 工具 Hook:对话弹窗状态
// 每个对话都需要 visible + step 两个状态以及"重置进度后打开/关闭"的语义,
// 抽到 hook 中可消除大量重复 useState 与 setXxxStep(0) 样板
// ============================================================================
const useDialog = () => {
const [visible, setVisible] = useState(false)
const [step, setStep] = useState(0)
const open = () => {
setStep(0)
setVisible(true)
}
const close = () => {
setStep(0)
setVisible(false)
}
return { visible, setVisible, step, setStep, open, close }
}
const SunzhongshanPlace1 = () => { const SunzhongshanPlace1 = () => {
const navigate = useNavigate() const navigate = useNavigate()
const { moduleName } = useParams<{ const { moduleName } = useParams<{ moduleName: string }>()
moduleName: string
}>()
// 获取碎片动画触发方法 // 拼图碎片掉落动画
const { triggerPuzzleAnimation } = usePuzzleAnimation() const { triggerPuzzleAnimation } = usePuzzleAnimation()
// 学习引导弹窗 // ===== 进入页面的两段引导(首段引导 → 顶层对话 → 后续引导) =====
const [showGuideModal, setShowGuideModal] = useState(true) const [showGuideModal, setShowGuideModal] = useState(true) // 进入即弹出的学习指南
// 对话结束后的学习引导弹窗 const [topDialogVisible, setTopDialogVisible] = useState(false) // 学习指南关闭后展示的开场对话
const [showAfterDialogGuide, setShowAfterDialogGuide] = useState(false) const [showAfterDialogGuide, setShowAfterDialogGuide] = useState(false) // 开场对话结束后弹出的二段指南
const [afterDialogGuideShown, setAfterDialogGuideShown] = useState(false) const [afterDialogGuideShown, setAfterDialogGuideShown] = useState(false) // 二段指南只展示一次
// 引导对话显示状态
const [visible, setVisible] = useState(false)
// 当前激活的展品 ID(只存 ID,不直接存路径,便于作为唯一标识)
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | 4 | 5 | null>(null)
// 使用持久化的访问状态
const place = 'place1'
const { markExhibitVisited, getVisitedExhibits } = useSunzhongshanStore()
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码 // ===== 当前激活展品(仅存 ID,路径通过 map 查询) =====
const [activeExhibitId, setActiveExhibitId] = useState<ExhibitId | null>(null)
// ===== 持久化的访问状态 =====
const { markExhibitVisited, getVisitedExhibits } = useSunzhongshanStore()
const visitedExhibits = getVisitedExhibits(place) const visitedExhibits = getVisitedExhibits(place)
const visited = new Set(visitedExhibits) const visited = new Set(visitedExhibits)
// 展品2窗口关闭的对话状态 // ===== Cursor 顺序控制:2 -> 3/1 -> 4 -> 5 -> 2 形成的依赖关系 =====
const [guide2CloseVisible, setGuide2CloseVisible] = useState(false)
const [guide2CloseStep, setGuide2CloseStep] = useState(0)
// Cursor 顺序控制:1 -> 2 -> 3 -> 4 -> 5
const { isEnabled } = useCursorController({ const { isEnabled } = useCursorController({
initialEnabled: [2], initialEnabled: [2],
dependencies: { dependencies: {
@@ -73,73 +114,123 @@ const SunzhongshanPlace1 = () => {
visited, visited,
}) })
// 光标2的对话状态 // ===== 各光标对应的引导对话状态 =====
const [guide2Visible, setGuide2Visible] = useState(false) const guide1 = useDialog() // 光标 1
const [guide2Step, setGuide2Step] = useState(0) const guide2 = useDialog() // 光标 2 打开后的对话
// 光标5的对话状态 const guide2Close = useDialog() // 光标 2 关闭后追加的承接对话
const [guide5Visible, setGuide5Visible] = useState(false) const guide3 = useDialog() // 光标 3
const [guide5Step, setGuide5Step] = useState(0) const guide4 = useDialog() // 光标 4
// 光标4的对话状态 const guide5 = useDialog() // 光标 5
const [guide4Visible, setGuide4Visible] = useState(false)
const [guide4Step, setGuide4Step] = useState(0) // ===== 光标 1 的多选题与成功提示 =====
// 光标4的视频状态 const [quiz1Visible, setQuiz1Visible] = useState(false)
const [successAlert1Visible, setSuccessAlert1Visible] = useState(false)
// ===== 光标 4 的视频 + 拖拽题 + 成功提示 =====
const [showVideo4, setShowVideo4] = useState(false) const [showVideo4, setShowVideo4] = useState(false)
const video4Ref = useRef<VideoRef>(null) const video4Ref = useRef<VideoRef>(null)
// 光标4的拖拽题状态
const [dragQuiz4Visible, setDragQuiz4Visible] = useState(false) const [dragQuiz4Visible, setDragQuiz4Visible] = useState(false)
const [dragQuiz4Value, setDragQuiz4Value] = useState<DragFillResult[]>([]) const [dragQuiz4Value, setDragQuiz4Value] = useState<DragFillResult[]>([])
const [dragQuiz4Error, setDragQuiz4Error] = useState('') const [dragQuiz4Error, setDragQuiz4Error] = useState('')
const [successAlert4Visible, setSuccessAlert4Visible] = useState(false) const [successAlert4Visible, setSuccessAlert4Visible] = useState(false)
// 光标4的第二个视频状态
// ===== 光标 3 关闭后衔接播放的视频(业务上沿用 video4Second 命名) =====
const [showVideo4Second, setShowVideo4Second] = useState(false) const [showVideo4Second, setShowVideo4Second] = useState(false)
const video4SecondRef = useRef<VideoRef>(null) const video4SecondRef = useRef<VideoRef>(null)
// 光标3的对话状态 // ===== 光标 3 视频结束后的填空题与成功提示 =====
const [guide3Visible, setGuide3Visible] = useState(false) const [quiz3Visible, setQuiz3Visible] = useState(false)
const [guide3Step, setGuide3Step] = useState(0) const [successAlert3Visible, setSuccessAlert3Visible] = useState(false)
// 光标1的对话状态
const [guide1Visible, setGuide1Visible] = useState(false)
const [guide1Step, setGuide1Step] = useState(0)
// 光标1的多选题状态
const [quiz1Visible, setQuiz1Visible] = useState(false)
const [successAlert1Visible, setSuccessAlert1Visible] = useState(false)
const handleOpenExhibit = (index: 1 | 2 | 3 | 4 | 5) => { // ===== 各展品 ID 对应的对话弹窗映射,用于按 ID 控制开/关 =====
setActiveExhibitId(index) const guideMap: Record<ExhibitId, ReturnType<typeof useDialog>> = {
// 根据不同的展品显示对应的对话,并重置进度 1: guide1,
if (index === 2) { 2: guide2,
setGuide2Step(0) 3: guide3,
setGuide2Visible(true) 4: guide4,
} else if (index === 5) { 5: guide5,
setGuide5Step(0)
setGuide5Visible(true)
} else if (index === 4) {
setGuide4Step(0)
setGuide4Visible(true)
} else if (index === 3) {
setGuide3Step(0)
setGuide3Visible(true)
} else if (index === 1) {
setGuide1Step(0)
setGuide1Visible(true)
}
} }
// ==========================================================================
// 业务流程
// ==========================================================================
// 点击光标:激活展品并打开对应对话
const handleOpenExhibit = (id: ExhibitId) => {
setActiveExhibitId(id)
guideMap[id].open()
}
// 展品遮罩打开时触发的拼图碎片动画
const handleOverlayOpen = () => {
if (activeExhibitId == null) return
const puzzleIdx = exhibitPuzzleMap[activeExhibitId]
if (puzzleIdx != null) triggerPuzzleAnimation(puzzleIdx)
}
/**
* 展品遮罩关闭时各展品的差异化后续流程:
* - 展品 1:关闭后弹出多选题,答对后才标记为已访问
* - 展品 2:关闭后弹出衔接对话;由 GuideSteps 的 onVisibleChange 标记访问
* - 展品 3:关闭后播放衔接视频,视频结束后标记已访问
* - 展品 4:关闭后播放视频 → 拖拽题 → 成功提示,结束后标记已访问
* - 展品 5:直接关闭即可,由 GuideSteps 的 onVisibleChange 标记访问
* 默认流程(展品 2 / 5 走到此):标记访问 + 清空 activeExhibitId
*/
const handleOverlayClose = () => {
const id = activeExhibitId
switch (id) {
case 1:
guide1.close()
setActiveExhibitId(null)
setQuiz1Visible(true)
return
case 2:
guide2.close()
guide2Close.open()
break // 走默认流程:标记 + 清空
case 3:
guide3.close()
setActiveExhibitId(null)
setShowVideo4Second(true)
return
case 4:
guide4.close()
setActiveExhibitId(null)
setShowVideo4(true)
return
case 5:
guide5.close()
break // 走默认流程
}
if (id !== null) markExhibitVisited(place, id)
setActiveExhibitId(null)
}
// 顶层"前进"按钮:保存上次访问场景并跳转下一个 place
const handleAdvance = () => {
const { setLastVisitedPlace } = useSunzhongshanStore.getState()
setLastVisitedPlace('place2')
saveSunzhongshanStore()
navigate(`/${moduleName}/${starName}/place2`)
}
// ===== 进度副标题 =====
const progressCount = visited.size const progressCount = visited.size
const total = 5 const completed = progressCount >= TOTAL_EXHIBITS
const completed = progressCount >= total const subtitle = completed ? '已完成' : `${progressCount}/${TOTAL_EXHIBITS}`
const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]' ? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined : undefined
// ==========================================================================
// 渲染
// ==========================================================================
return ( return (
<div <div
className="w-full h-full bg-cover bg-center bg-no-repeat" className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{ style={{ backgroundImage: getBackgroundImage(`sunzhongshan/place1/背景`) }}
backgroundImage: getBackgroundImage(`sunzhongshan/place1/背景`),
}}
> >
{/* 标题 */} {/* 标题与进度 */}
<Title <Title
title="立志救国·推翻帝制" title="立志救国·推翻帝制"
subtitle={subtitle} subtitle={subtitle}
@@ -147,6 +238,7 @@ const SunzhongshanPlace1 = () => {
className="absolute top-10 left-1/2 -translate-x-1/2" className="absolute top-10 left-1/2 -translate-x-1/2"
/> />
{/* 离开按钮:返回当前角色的 home */}
<Image <Image
className="absolute right-5 top-5" className="absolute right-5 top-5"
src={getStaticImage('components/离开')} src={getStaticImage('components/离开')}
@@ -158,123 +250,45 @@ const SunzhongshanPlace1 = () => {
}} }}
/> />
{/* 光标1 */} {/* 光标列表(由配置数组渲染,避免 5 段重复 JSX) */}
<Cursor {cursorList.map(({ id, className }) => (
className="absolute left-75 bottom-80" <Cursor
onClick={() => handleOpenExhibit(1)} key={id}
read={visited.has(1)} className={className}
disabled={!isEnabled(1)} onClick={() => handleOpenExhibit(id)}
/> read={visited.has(id)}
{/* 光标2 */} disabled={!isEnabled(id)}
<Cursor />
className="absolute left-108 bottom-118" ))}
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
{/* 光标3 */}
<Cursor
className="absolute left-165 bottom-90"
onClick={() => handleOpenExhibit(3)}
read={visited.has(3)}
disabled={!isEnabled(3)}
/>
{/* 光标4 */}
<Cursor
className="absolute right-192 bottom-118"
onClick={() => handleOpenExhibit(4)}
read={visited.has(4)}
disabled={!isEnabled(4)}
/>
{/* 光标5 */}
<Cursor
className="absolute right-188 bottom-88"
onClick={() => handleOpenExhibit(5)}
read={visited.has(5)}
disabled={!isEnabled(5)}
/>
{/* 继续前进 - 仅在完成所有展品后显示 */} {/* 全部展品学习完成后才显示前进按钮 */}
{completed && ( {completed && (
<Image <Image
className="absolute bottom-10 right-0" className="absolute bottom-10 right-0"
src={getStaticImage('components/place/r-3d前进')} src={getStaticImage('components/place/r-3d前进')}
button button
onClick={() => { onClick={handleAdvance}
const { setLastVisitedPlace } = useSunzhongshanStore.getState()
setLastVisitedPlace('place2')
saveSunzhongshanStore()
navigate(`/${moduleName}/${starName}/place2`)
}}
/> />
)} )}
{/* 遮罩层 */} {/* 展品遮罩:展示放大的展品图,并在打开/关闭时驱动后续流程 */}
<ExhibitOverlay <ExhibitOverlay
visible={activeExhibitId !== null} visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''} src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
onOpen={() => { onOpen={handleOverlayOpen}
if (activeExhibitId === 2) { onClose={handleOverlayClose}
triggerPuzzleAnimation(1)
}
if (activeExhibitId === 5) {
triggerPuzzleAnimation(2)
}
if (activeExhibitId === 4) {
triggerPuzzleAnimation(3)
}
if (activeExhibitId === 3) {
triggerPuzzleAnimation(4)
}
}}
onClose={() => {
const currentId = activeExhibitId
// 关闭展品框时,重置对应对话的进度并隐藏对话
if (currentId === 2) {
setGuide2Step(0)
setGuide2Visible(false)
setGuide2CloseStep(0)
setGuide2CloseVisible(true)
} else if (currentId === 5) {
setGuide5Step(0)
setGuide5Visible(false)
} else if (currentId === 4) {
setGuide4Step(0)
setGuide4Visible(false)
// 光标4:关闭窗口后播放视频
setActiveExhibitId(null)
setShowVideo4(true)
return
} else if (currentId === 3) {
setGuide3Step(0)
setGuide3Visible(false)
setActiveExhibitId(null)
setShowVideo4Second(true)
return
} else if (currentId === 1) {
setGuide1Step(0)
setGuide1Visible(false)
// 光标1:关闭窗口后显示题目
setActiveExhibitId(null)
setQuiz1Visible(true)
return
}
if (currentId !== null) {
markExhibitVisited(place, currentId)
}
setActiveExhibitId(null)
}}
/> />
{/* 学习引导词 */} {/* ====================================================================
顶层引导:学习指南 → 开场对话 → 二段学习指南
==================================================================== */}
<StudyGuideModal <StudyGuideModal
visible={showGuideModal} visible={showGuideModal}
onContinue={() => { onContinue={() => {
setShowGuideModal(false) setShowGuideModal(false)
setVisible(true) setTopDialogVisible(true)
}} }}
audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_02.mp3')} audioSrc={audio('02')}
ailgn="left" ailgn="left"
> >
1.场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态。 1.场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态。
@@ -284,11 +298,11 @@ const SunzhongshanPlace1 = () => {
3.所有动态光点的展品全部学习后才能进入下一个场景学习。 3.所有动态光点的展品全部学习后才能进入下一个场景学习。
</StudyGuideModal> </StudyGuideModal>
{/* 引导 */}
<GuideSteps <GuideSteps
visible={visible} visible={topDialogVisible}
onVisibleChange={(nextVisible) => { onVisibleChange={(nextVisible) => {
setVisible(nextVisible) setTopDialogVisible(nextVisible)
// 开场对话结束后,仅首次触发后续引导
if (!nextVisible && !afterDialogGuideShown) { if (!nextVisible && !afterDialogGuideShown) {
setAfterDialogGuideShown(true) setAfterDialogGuideShown(true)
setShowAfterDialogGuide(true) setShowAfterDialogGuide(true)
@@ -297,152 +311,121 @@ const SunzhongshanPlace1 = () => {
showMask={false} showMask={false}
highlightSelectors={[]} highlightSelectors={[]}
> >
<GuideStepsItem <GuideStepsItem speaker="student" audioSrc={audio('03')}>
speaker="student"
audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_03.mp3')}
>
小沪,老师说过,孙中山先生早年学习的是医学。从治病救人到革命救国,他为什么会做出这种转变呢? 小沪,老师说过,孙中山先生早年学习的是医学。从治病救人到革命救国,他为什么会做出这种转变呢?
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem <GuideStepsItem speaker="guide" audioSrc={audio('04')}>
speaker="guide"
audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_04.mp3')}
>
来看看这张孙中山先生青年时期的相片吧。 来看看这张孙中山先生青年时期的相片吧。
</GuideStepsItem> </GuideStepsItem>
</GuideSteps> </GuideSteps>
{/* 对话结束后的学习引导 */}
<StudyGuideModal <StudyGuideModal
visible={showAfterDialogGuide} visible={showAfterDialogGuide}
onContinue={() => setShowAfterDialogGuide(false)} onContinue={() => setShowAfterDialogGuide(false)}
audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_01.mp3')} audioSrc={audio('01')}
firstLineIndent="2em"
> >
{`同学你好,欢迎来到“走进故居·对话”学习模块! {`同学你好,欢迎来到“走进故居·对话”学习模块!
此刻你正置身于孙中山故居堂屋,接下来请随时光的脚步,一同走进他人生中四段激情燃烧的岁月:立志救国·推翻帝制、讨袁护法·捍卫共和、与时俱进·探索新路、抱病北上·鞠躬尽瘁。做好准备了吗?开始探索这段波澜壮阔的历史征程吧!`} 此刻你正置身于孙中山故居堂屋,接下来请随时光的脚步,一同走进他人生中四段激情燃烧的岁月:立志救国·推翻帝制、讨袁护法·捍卫共和、与时俱进·探索新路、抱病北上·鞠躬尽瘁。做好准备了吗?开始探索这段波澜壮阔的历史征程吧!`}
</StudyGuideModal> </StudyGuideModal>
{/* 光标2的对话 */} {/* ====================================================================
光标 2:四大寇合影 —— 关闭后追加一段衔接对话
==================================================================== */}
<GuideSteps <GuideSteps
visible={guide2Visible} visible={guide2.visible}
currentStep={guide2Step} currentStep={guide2.step}
onStepChange={setGuide2Step} onStepChange={guide2.setStep}
onVisibleChange={(nextVisible) => { onVisibleChange={(nextVisible) => {
setGuide2Visible(nextVisible) guide2.setVisible(nextVisible)
if (!nextVisible) { // 用户从对话内部关闭时直接标记已访问(与 overlay 关闭路径都会到达)
markExhibitVisited(place, 2) if (!nextVisible) markExhibitVisited(place, 2)
}
}} }}
showMask={false} showMask={false}
highlightSelectors={[]} highlightSelectors={[]}
zIndex={10000} zIndex={10000}
> >
<GuideStepsItem <GuideStepsItem speaker="student" audioSrc={audio('05')}>
speaker="student"
audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_05.mp3')}
>
“革命四杰”? “革命四杰”?
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem <GuideStepsItem speaker="guide" audioSrc={audio('06')}>
speaker="guide"
audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_06.mp3')}
>
是呀,这是孙中山先生在香港西医书院就读之时与同学的留影。孙先生1887年入学,其间广泛阅读西方自然科学和社会政治学说书籍。常与同学陈少白、尤列、杨鹤龄一起畅谈革命,抨击时政,被戏称为“四大寇”。 是呀,这是孙中山先生在香港西医书院就读之时与同学的留影。孙先生1887年入学,其间广泛阅读西方自然科学和社会政治学说书籍。常与同学陈少白、尤列、杨鹤龄一起畅谈革命,抨击时政,被戏称为“四大寇”。
</GuideStepsItem> </GuideStepsItem>
</GuideSteps> </GuideSteps>
{/* 展品2窗口关闭的对话 */} {/* 展品 2 关闭后追加的对话(独立 GuideSteps,不影响主对话) */}
<GuideSteps <GuideSteps
visible={guide2CloseVisible} visible={guide2Close.visible}
currentStep={guide2CloseStep} currentStep={guide2Close.step}
onStepChange={setGuide2CloseStep} onStepChange={guide2Close.setStep}
onVisibleChange={(nextVisible) => { onVisibleChange={guide2Close.setVisible}
setGuide2CloseVisible(nextVisible)
}}
showMask={false} showMask={false}
zIndex={10000} zIndex={10000}
> >
<GuideStepsItem <GuideStepsItem speaker="student" audioSrc={audio('07')}>
speaker="student"
audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_07.mp3')}
>
原来孙先生早在青年时期就把国家前途命运放在了心上。 原来孙先生早在青年时期就把国家前途命运放在了心上。
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem <GuideStepsItem speaker="guide" audioSrc={audio('08')}>
speaker="guide"
audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_08.mp3')}
>
不仅是关心,他还付诸了实际行动。看看这件展品。 不仅是关心,他还付诸了实际行动。看看这件展品。
</GuideStepsItem> </GuideStepsItem>
</GuideSteps> </GuideSteps>
{/* 光标5的对话 */} {/* ====================================================================
光标 5:郑观应推荐信
==================================================================== */}
<GuideSteps <GuideSteps
visible={guide5Visible} visible={guide5.visible}
currentStep={guide5Step} currentStep={guide5.step}
onStepChange={setGuide5Step} onStepChange={guide5.setStep}
onVisibleChange={(nextVisible) => { onVisibleChange={(nextVisible) => {
setGuide5Visible(nextVisible) guide5.setVisible(nextVisible)
if (!nextVisible) { if (!nextVisible) markExhibitVisited(place, 5)
markExhibitVisited(place, 5)
}
}} }}
showMask={false} showMask={false}
highlightSelectors={[]} highlightSelectors={[]}
zIndex={10000} zIndex={10000}
> >
<GuideStepsItem <GuideStepsItem speaker="student" audioSrc={audio('09')}>
speaker="student"
audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_09.mp3')}
>
这是郑观应写给盛宣怀的信。 这是郑观应写给盛宣怀的信。
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem <GuideStepsItem speaker="guide" audioSrc={audio('10')}>
speaker="guide"
audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_10.mp3')}
>
是的。1894年,孙中山先生回乡起草了“上李鸿章书”,希望劝谏清政府改良救国。他多方寻找上书门径,这就是作为同乡和前辈的郑观应帮忙写给盛宣怀,希望他向李鸿章推荐孙先生的书信。 是的。1894年,孙中山先生回乡起草了“上李鸿章书”,希望劝谏清政府改良救国。他多方寻找上书门径,这就是作为同乡和前辈的郑观应帮忙写给盛宣怀,希望他向李鸿章推荐孙先生的书信。
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem <GuideStepsItem speaker="student" audioSrc={audio('11')}>
speaker="student"
audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_11.mp3')}
>
那么,这次上书最后成功了吗? 那么,这次上书最后成功了吗?
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem <GuideStepsItem speaker="guide" audioSrc={audio('12')}>
speaker="guide"
audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_12.mp3')}
>
1894年春天,朝鲜农民起义爆发。孙中山先生的上书于这一年的6月投递,可惜当时甲午战争已经箭在弦上,李鸿章忙于备战,并未予以接见。这件事使得孙先生放弃了依靠清政府改良救国的幻想,转而投身革命,以求彻底推翻封建君主专制制度。 1894年春天,朝鲜农民起义爆发。孙中山先生的上书于这一年的6月投递,可惜当时甲午战争已经箭在弦上,李鸿章忙于备战,并未予以接见。这件事使得孙先生放弃了依靠清政府改良救国的幻想,转而投身革命,以求彻底推翻封建君主专制制度。
</GuideStepsItem> </GuideStepsItem>
</GuideSteps> </GuideSteps>
{/* 光标4的对话 */} {/* ====================================================================
光标 4:申报 —— 关闭后播放视频 → 拖拽题 → 成功提示
==================================================================== */}
<GuideSteps <GuideSteps
visible={guide4Visible} visible={guide4.visible}
currentStep={guide4Step} currentStep={guide4.step}
onStepChange={setGuide4Step} onStepChange={guide4.setStep}
onVisibleChange={(nextVisible) => { onVisibleChange={guide4.setVisible}
setGuide4Visible(nextVisible)
}}
showMask={false} showMask={false}
highlightSelectors={[]} highlightSelectors={[]}
zIndex={10000} zIndex={10000}
> >
<GuideStepsItem <GuideStepsItem speaker="student" audioSrc={audio('13')}>
speaker="student"
audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_13.mp3')}
>
哇!这张《申报》好震撼,“中华民国万岁”“孙大总统万岁”,就连发行日期写的都是“中华民国元年元月二日”! 哇!这张《申报》好震撼,“中华民国万岁”“孙大总统万岁”,就连发行日期写的都是“中华民国元年元月二日”!
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem <GuideStepsItem speaker="guide" audioSrc={audio('14')}>
speaker="guide"
audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_14.mp3')}
>
没错!资产阶级民主革命的尝试走过了风雨兼程的十余年,这一过程中孙中山先生一直在探寻救国道路,1905年孙先生在亲自撰写的《民报》发刊词中明确提出了三民主义,即民族、民权、民生这一资产阶级民主革命的政纲。 没错!资产阶级民主革命的尝试走过了风雨兼程的十余年,这一过程中孙中山先生一直在探寻救国道路,1905年孙先生在亲自撰写的《民报》发刊词中明确提出了三民主义,即民族、民权、民生这一资产阶级民主革命的政纲。
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={audio('15')}>
这些尝试终于在武昌起义过后迎来了回响。1911年年末,湖南、广东等14个省和上海纷纷宣布脱离清政府独立。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
1912年1月1日上午,上海有一万多人聚集在沪宁车站,欢送孙先生前去南京就任临时大总统。当天夜里11点,孙先生在原清朝两江总督府宣誓就职,新的共和政体就此诞生。彼时仍属农历辛亥年,因此历史上将这一系列事件称为“辛亥革命”。
</GuideStepsItem>
</GuideSteps> </GuideSteps>
{/* 光标4的第一个视频 */} {/* 光标 4 衔接的视频:播放结束 → 进入拖拽题 */}
{showVideo4 && ( {showVideo4 && (
<Video <Video
ref={video4Ref} ref={video4Ref}
@@ -458,7 +441,7 @@ const SunzhongshanPlace1 = () => {
/> />
)} )}
{/* 光标4的拖拽题 */} {/* 光标 4 的拖拽题:要求三个空白填入 民族 / 民权 / 民生(顺序不限) */}
<DragQuizPanel <DragQuizPanel
visible={dragQuiz4Visible} visible={dragQuiz4Visible}
zIndex={10000} zIndex={10000}
@@ -481,8 +464,9 @@ const SunzhongshanPlace1 = () => {
onChange={(value) => { onChange={(value) => {
setDragQuiz4Value(value) setDragQuiz4Value(value)
setDragQuiz4Error('') setDragQuiz4Error('')
// 三个空白未填满前不参与判定
if (value.length < 3) return if (value.length < 3) return
// 检查答案:需要三个空白都填满,且答案正确 // 集合判等:忽略顺序,三个值需恰好等于 民族 / 民权 / 民生
const correctAnswers = ['民族', '民权', '民生'] const correctAnswers = ['民族', '民权', '民生']
const filledAnswers = value.map((v) => v.optionValue) const filledAnswers = value.map((v) => v.optionValue)
const answerSet = new Set(correctAnswers) const answerSet = new Set(correctAnswers)
@@ -501,20 +485,16 @@ const SunzhongshanPlace1 = () => {
}} }}
/> />
{/* 光标4的成功提示 */} {/* 光标 4 答对后的成功提示:关闭时标记展品 4 已访问 */}
<SuccessAlert <SuccessAlert
visible={successAlert4Visible} visible={successAlert4Visible}
// onContinue={() => {
// setSuccessAlert4Visible(false)
// setShowVideo4Second(true);
// }}
onClose={() => { onClose={() => {
setSuccessAlert4Visible(false) setSuccessAlert4Visible(false)
markExhibitVisited(place, 4) markExhibitVisited(place, 4)
}} }}
/> />
{/* 光标4的第二个视频 */} {/* 光标 3 关闭后衔接的视频:播放结束后展示填空题(答对后才标记展品 3 已访问) */}
{showVideo4Second && ( {showVideo4Second && (
<Video <Video
ref={video4SecondRef} ref={video4SecondRef}
@@ -525,42 +505,61 @@ const SunzhongshanPlace1 = () => {
showSkipButton showSkipButton
onEnded={() => { onEnded={() => {
setShowVideo4Second(false) setShowVideo4Second(false)
// 视频播放结束后,标记展品3为已访问 setQuiz3Visible(true)
markExhibitVisited(place, 3)
}} }}
/> />
)} )}
{/* 光标3的对话 */} {/* 光标 3 的填空题:辛亥革命爆发于公元 1911 年(4 个空,依次填 1/9/1/1) */}
<GuideSteps <SelectFillQuizPanel
visible={guide3Visible} visible={quiz3Visible}
currentStep={guide3Step} zIndex={10000}
onStepChange={setGuide3Step} titlePrefix="【填空】辛亥革命爆发于中国农历辛亥年,即公元"
onVisibleChange={(nextVisible) => { titleSuffix="年。"
setGuide3Visible(nextVisible) blanks={[{ id: 'b1' }, { id: 'b2' }, { id: 'b3' }, { id: 'b4' }]}
options={Array.from({ length: 10 }, (_, i) => ({
value: String(i),
label: String(i),
}))}
correctAnswers={{ b1: '1', b2: '9', b3: '1', b4: '1' }}
errorMessage="辛亥革命是指发生于中国农历辛亥年,即公元1911年至1912年初,旨在推翻清朝专制帝制、建立共和政体的全国性革命。请你再想想吧!"
onSubmit={(_results, isCorrect) => {
if (isCorrect) {
setQuiz3Visible(false)
setSuccessAlert3Visible(true)
}
}} }}
/>
{/* 光标 3 答对后的成功提示:关闭时才标记展品 3 已访问 */}
<SuccessAlert
visible={successAlert3Visible}
title="恭喜你, 选择正确!"
onContinue={() => {
setSuccessAlert3Visible(false)
markExhibitVisited(place, 3)
}}
onClose={() => {
setSuccessAlert3Visible(false)
markExhibitVisited(place, 3)
}}
/>
{/* ====================================================================
光标 3:民立报
==================================================================== */}
<GuideSteps
visible={guide3.visible}
currentStep={guide3.step}
onStepChange={guide3.setStep}
onVisibleChange={guide3.setVisible}
showMask={false} showMask={false}
zIndex={10000} zIndex={10000}
> >
<GuideStepsItem <GuideStepsItem speaker="student" audioSrc={audio('16')}>
speaker="guide"
audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_15.mp3')}
>
这些尝试终于在武昌起义过后迎来了回响。1911年年末,湖南、广东等14个省和上海纷纷宣布脱离清政府独立。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
1912年1月1日上午,上海有一万多人聚集在沪宁车站,欢送孙先生前去南京就任临时大总统。当天夜里11点,孙先生在原清朝两江总督府宣誓就职,新的共和政体就此诞生。彼时仍属农历辛亥年,因此历史上将这一系列事件称为“辛亥革命”。
</GuideStepsItem>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_16.mp3')}
>
孙中山先生领导的辛亥革命推动了中国社会的变革,可是这张《民立报》让我有点不明白,孙先生刚当上临时大总统,为什么又很快宣布“解职”了呀? 孙中山先生领导的辛亥革命推动了中国社会的变革,可是这张《民立报》让我有点不明白,孙先生刚当上临时大总统,为什么又很快宣布“解职”了呀?
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem <GuideStepsItem speaker="guide" audioSrc={audio('17')}>
speaker="guide"
audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_17.mp3')}
>
中华民国的建立有个重要背景——“南北议和”。辛亥革命中宣告独立的省份大多集中在中国南方,负隅顽抗的清政府将最后希望寄托在主导北洋军阀势力的袁世凯身上。在列强支持下,袁世凯率军南下。 中华民国的建立有个重要背景——“南北议和”。辛亥革命中宣告独立的省份大多集中在中国南方,负隅顽抗的清政府将最后希望寄托在主导北洋军阀势力的袁世凯身上。在列强支持下,袁世凯率军南下。
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem speaker="guide"> <GuideStepsItem speaker="guide">
@@ -568,45 +567,33 @@ const SunzhongshanPlace1 = () => {
</GuideStepsItem> </GuideStepsItem>
</GuideSteps> </GuideSteps>
{/* 光标1的对话 */} {/* ====================================================================
光标 1:南北议和纪念章 —— 关闭后展示多选题
==================================================================== */}
<GuideSteps <GuideSteps
visible={guide1Visible} visible={guide1.visible}
currentStep={guide1Step} currentStep={guide1.step}
onStepChange={setGuide1Step} onStepChange={guide1.setStep}
onVisibleChange={(nextVisible) => { onVisibleChange={guide1.setVisible}
setGuide1Visible(nextVisible)
}}
showMask={false} showMask={false}
highlightSelectors={[]} highlightSelectors={[]}
zIndex={10000} zIndex={10000}
> >
<GuideStepsItem <GuideStepsItem speaker="student" audioSrc={audio('18')}>
speaker="student"
audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_18.mp3')}
>
这算不算一种"妥协"呀? 这算不算一种"妥协"呀?
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem <GuideStepsItem speaker="guide" audioSrc={audio('19')}>
speaker="guide"
audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_19.mp3')}
>
可以说,这是当时客观条件限制下的一种无奈妥协。因为革命党人的首要目标是推翻帝制,在武装力量不足以对抗北洋军阀的情况下,只能暂时信任袁世凯,希望他能维护共和。你看旁边的“南北议和”纪念章和纪念墨盒,就是当时这段历史的见证。 可以说,这是当时客观条件限制下的一种无奈妥协。因为革命党人的首要目标是推翻帝制,在武装力量不足以对抗北洋军阀的情况下,只能暂时信任袁世凯,希望他能维护共和。你看旁边的“南北议和”纪念章和纪念墨盒,就是当时这段历史的见证。
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem <GuideStepsItem speaker="student" audioSrc={audio('20')}>
speaker="student"
audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_20.mp3')}
>
可后来袁世凯不是复辟当皇帝了吗?那孙中山先生是不是特别后悔呀? 可后来袁世凯不是复辟当皇帝了吗?那孙中山先生是不是特别后悔呀?
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem <GuideStepsItem speaker="guide" audioSrc={audio('21')}>
speaker="guide"
audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_21.mp3')}
>
的确,所以辛亥革命未能改变旧中国半殖民地半封建的社会性质和中国人民的悲惨境遇,也未能完成实现民族独立、人民解放的历史任务。孙中山先生十分痛心,但也更坚定了捍卫共和的决心。这也为他后来发起二次革命、护法运动埋下了伏笔。 的确,所以辛亥革命未能改变旧中国半殖民地半封建的社会性质和中国人民的悲惨境遇,也未能完成实现民族独立、人民解放的历史任务。孙中山先生十分痛心,但也更坚定了捍卫共和的决心。这也为他后来发起二次革命、护法运动埋下了伏笔。
</GuideStepsItem> </GuideStepsItem>
</GuideSteps> </GuideSteps>
{/* 光标1的多选题 */} {/* 光标 1 的多选题(A/B/C 正确,D 为干扰项) */}
<MultipleChoiceQuizPanel <MultipleChoiceQuizPanel
visible={quiz1Visible} visible={quiz1Visible}
zIndex={10000} zIndex={10000}
@@ -619,7 +606,7 @@ const SunzhongshanPlace1 = () => {
]} ]}
correctAnswer={['1', '2', '3']} correctAnswer={['1', '2', '3']}
errorMessage="辛亥革命未能改变旧中国半殖民地半封建的社会性质和中国人民的悲惨境遇,因此也未能完成实现民族独立、人民解放的历史任务。但它拉开了中国完全意义上的近代民族民主革命的序幕。" errorMessage="辛亥革命未能改变旧中国半殖民地半封建的社会性质和中国人民的悲惨境遇,因此也未能完成实现民族独立、人民解放的历史任务。但它拉开了中国完全意义上的近代民族民主革命的序幕。"
onSubmit={(values: string[], isCorrect: boolean) => { onSubmit={(_values: string[], isCorrect: boolean) => {
if (isCorrect) { if (isCorrect) {
setQuiz1Visible(false) setQuiz1Visible(false)
setSuccessAlert1Visible(true) setSuccessAlert1Visible(true)
@@ -627,18 +614,16 @@ const SunzhongshanPlace1 = () => {
}} }}
/> />
{/* 光标1的成功提示 */} {/* 光标 1 答对后的成功提示:onContinue / onClose 任一关闭都标记展品 1 已访问 */}
<SuccessAlert <SuccessAlert
visible={successAlert1Visible} visible={successAlert1Visible}
title="恭喜你, 选择正确!" title="恭喜你, 选择正确!"
onContinue={() => { onContinue={() => {
setSuccessAlert1Visible(false) setSuccessAlert1Visible(false)
// 修改cursor状态
markExhibitVisited(place, 1) markExhibitVisited(place, 1)
}} }}
onClose={() => { onClose={() => {
setSuccessAlert1Visible(false) setSuccessAlert1Visible(false)
// 修改cursor状态
markExhibitVisited(place, 1) markExhibitVisited(place, 1)
}} }}
/> />
+9 -6
View File
@@ -259,7 +259,11 @@ const SunzhongshanReflectionScene1 = () => {
// 获取线索提示图片 // 获取线索提示图片
const getClueHintImage = (clueId: string): string | null => { const getClueHintImage = (clueId: string): string | null => {
const clueNumber = clueId.split('clue')[1] const clueNumber = clueId.split('clue')[1]
return getStaticImage(`${sceneId}/线索${clueNumber}-详情的详情`) // 场景1中线索1有提示
if (clueNumber && ['1'].includes(clueNumber)) {
return getStaticImage(`${sceneId}/线索${clueNumber}-详情的详情`)
}
return null
} }
// 初始化线索(如果还没有) // 初始化线索(如果还没有)
@@ -442,14 +446,13 @@ const SunzhongshanReflectionScene1 = () => {
<StudyGuideModal <StudyGuideModal
visible={showGuide} visible={showGuide}
onContinue={handleGuideClose} onContinue={handleGuideClose}
firstLineIndent="2em"
zIndex={10000}
audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_48.mp3')} audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_48.mp3')}
zIndex={10000}
firstLineIndent="2em"
> >
同学你好,欢迎来到二十世纪初的上海。 同学你好,欢迎来到二十世纪初的上海。{'\n'}
<br />
辛亥革命之后,民主共和的观念深入人心。面对中外反动势力,社会各界更广泛地行动起来,以各自的方式争取着民主自由和国家尊严。 辛亥革命之后,民主共和的观念深入人心。面对中外反动势力,社会各界更广泛地行动起来,以各自的方式争取着民主自由和国家尊严。
<br /> {'\n'}
你需要在本场景中找出4条线索,并正确回答所有问题后,才能拨开时局迷雾,进入下一个场景。 你需要在本场景中找出4条线索,并正确回答所有问题后,才能拨开时局迷雾,进入下一个场景。
</StudyGuideModal> </StudyGuideModal>
+3 -2
View File
@@ -462,11 +462,12 @@ const SunzhongshanReflectionScene2 = () => {
onContinue={handleGuideClose} onContinue={handleGuideClose}
zIndex={10000} zIndex={10000}
audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_51.mp3')} audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_51.mp3')}
firstLineIndent="2em"
> >
1921年7月,中国共产党第一次全国代表大会在上海召开,中国革命的面貌自此焕然一新。 1921年7月,中国共产党第一次全国代表大会在上海召开,中国革命的面貌自此焕然一新。
<br /> {'\n'}
1921-1923的两年间,你顺利完成了一、二年级的在校学习任务,并在三年级进入工厂实习。身为学子,你专注于业务精进,同时亦非常关注时局的变化。一些同学和工友加入了党组织,你也对此心生向往…… 1921-1923的两年间,你顺利完成了一、二年级的在校学习任务,并在三年级进入工厂实习。身为学子,你专注于业务精进,同时亦非常关注时局的变化。一些同学和工友加入了党组织,你也对此心生向往……
<br /> {'\n'}
你需要在本场景中找出5条线索,并正确回答所有问题后,才能拨开时局迷雾,进入下一个场景。 你需要在本场景中找出5条线索,并正确回答所有问题后,才能拨开时局迷雾,进入下一个场景。
</StudyGuideModal> </StudyGuideModal>
@@ -517,6 +517,7 @@ const SunzhongshanReflectionScene3 = () => {
onContinue={handleGuideClose} onContinue={handleGuideClose}
audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_53.mp3')} audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_53.mp3')}
zIndex={10000} zIndex={10000}
firstLineIndent="2em"
> >
后来,学校建立了中国社会主义青年团团支部,你积极靠拢,成为一名光荣的团员。 后来,学校建立了中国社会主义青年团团支部,你积极靠拢,成为一名光荣的团员。
国共合作大背景下,国民革命正在如火如荼地开展,北伐也在积极酝酿之中。你在自己的岗位上认真工作,也密切关注着时局和两党动态。 国共合作大背景下,国民革命正在如火如荼地开展,北伐也在积极酝酿之中。你在自己的岗位上认真工作,也密切关注着时局和两党动态。
+6 -3
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { ExhibitFlipOverlay, FlipActionButton, Image } from '@/components' import { ExhibitFlipOverlay, FlipActionButton, Image } from '@/components'
import { cn, getBackgroundImage, getStaticImage } from '@/lib/utils' import { cn, getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { saveZhanglepingStore, useZhanglepingStore } from '@/store/zhanglepingStore' import { saveZhanglepingStore, useZhanglepingStore } from '@/store/zhanglepingStore'
import { import {
@@ -258,9 +258,12 @@ const ZhanglepingDraw1 = () => {
visible={showCompletionDialogue} visible={showCompletionDialogue}
onVisibleChange={setShowCompletionDialogue} onVisibleChange={setShowCompletionDialogue}
showMask={false} showMask={false}
zIndex={10020} audioChannelId="zhangleping-draw-completion"
zIndex={10000}
> >
<SanmaoDialogueItem>{COMPLETION_DIALOGUE}</SanmaoDialogueItem> <SanmaoDialogueItem audioSrc={getPublicPath('audio/zhangleping/mp3_zhangleping_30_1.mp3')}>
{COMPLETION_DIALOGUE}
</SanmaoDialogueItem>
</SanmaoDialogueSteps> </SanmaoDialogueSteps>
</div> </div>
) )
+34 -6
View File
@@ -1,6 +1,9 @@
import { useEffect } from 'react'
import { getBackgroundImage, getStaticImage } from '@/lib/utils' import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components' import { Image } from '@/components'
import { usePuzzleStore } from '@/store/puzzleStore'
import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression'
import { useZhanglepingStore } from '@/store/zhanglepingStore' import { useZhanglepingStore } from '@/store/zhanglepingStore'
const ZhanglepingHome = () => { const ZhanglepingHome = () => {
@@ -13,6 +16,17 @@ const ZhanglepingHome = () => {
const starName = 'zhangleping' const starName = 'zhangleping'
const lastVisitedPlace = useZhanglepingStore((state) => state.lastVisitedPlace) const lastVisitedPlace = useZhanglepingStore((state) => state.lastVisitedPlace)
const puzzles = usePuzzleStore((state) => state.puzzles)
const isLoaded = usePuzzleStore((state) => state.isLoaded)
const { dialogueUnlocked, reflectionUnlocked, internalizationUnlocked } =
getResidenceStageUnlocks(starName, puzzles)
useEffect(() => {
if (isLoaded && !isResidenceUnlocked(starName, puzzles)) {
navigate('/menu', { replace: true })
}
}, [isLoaded, navigate, puzzles, starName])
if (!moduleName) return null if (!moduleName) return null
return ( return (
@@ -32,20 +46,34 @@ const ZhanglepingHome = () => {
<div className="absolute right-20 top-75 flex flex-col space-y-10"> <div className="absolute right-20 top-75 flex flex-col space-y-10">
{/* 对话 */} {/* 对话 */}
<Image <Image
button button={dialogueUnlocked}
className={dialogueUnlocked ? undefined : 'opacity-50 grayscale cursor-not-allowed'}
src={getStaticImage('star/一-解锁')} src={getStaticImage('star/一-解锁')}
onClick={() => navigate(`/${moduleName}/${starName}/${lastVisitedPlace || 'place1'}`)} onClick={() => {
if (!dialogueUnlocked) return
navigate(`/${moduleName}/${starName}/${lastVisitedPlace || 'place1'}`)
}}
/> />
{/* 体悟 / 内化 预留 */} {/* 体悟 / 内化 预留 */}
<Image <Image
button button={reflectionUnlocked}
className={reflectionUnlocked ? undefined : 'opacity-50 grayscale cursor-not-allowed'}
src={getStaticImage('star/二-解锁')} src={getStaticImage('star/二-解锁')}
onClick={() => navigate(`/${moduleName}/${starName}/reflection`)} onClick={() => {
if (!reflectionUnlocked) return
navigate(`/${moduleName}/${starName}/reflection`)
}}
/> />
<Image <Image
button button={internalizationUnlocked}
className={
internalizationUnlocked ? undefined : 'opacity-50 grayscale cursor-not-allowed'
}
src={getStaticImage('star/三-解锁')} src={getStaticImage('star/三-解锁')}
onClick={() => navigate(`/${moduleName}/${starName}/internalization`)} onClick={() => {
if (!internalizationUnlocked) return
navigate(`/${moduleName}/${starName}/internalization`)
}}
/> />
</div> </div>
</div> </div>
+1 -1
View File
@@ -172,7 +172,7 @@ const ZhanglepingPlace5 = () => {
speaker="student" speaker="student"
audioSrc={getPublicPath('audio/zhangleping/mp3_zhangleping_16.mp3')} audioSrc={getPublicPath('audio/zhangleping/mp3_zhangleping_16.mp3')}
> >
朋友画我!这一整面墙的朋友笔下的张乐平先生和三毛真是各有特色啊! 小沪,三毛系列漫画真的太深入人心了!张乐平先生还会和“小三毛迷”们互通书信,张先生真的是非常喜欢儿童呀!
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem <GuideStepsItem
speaker="guide" speaker="guide"
@@ -119,7 +119,9 @@ const ZhanglepingReflection = () => {
firstLineIndent="2em" firstLineIndent="2em"
> >
同学你好,欢迎来到“跨越时空·体悟”学习模块! 同学你好,欢迎来到“跨越时空·体悟”学习模块!
{'\n'}
请登陆“时空回溯・亲历时代”体验仓,下一站:1930年,上海。 请登陆“时空回溯・亲历时代”体验仓,下一站:1930年,上海。
{'\n'}
三毛向你发送求助——他的“成长记忆碎片”散落在张乐平故居的5个关键场景中。你需要化身“时光探员”,收集记忆碎片,拼出三毛的成长故事。时空门已开,请做好准备吧! 三毛向你发送求助——他的“成长记忆碎片”散落在张乐平故居的5个关键场景中。你需要化身“时光探员”,收集记忆碎片,拼出三毛的成长故事。时空门已开,请做好准备吧!
</StudyGuideModal> </StudyGuideModal>
+9
View File
@@ -24,15 +24,24 @@ export const NieerRoutes: RouteObject[] = [
{ {
index: true, index: true,
element: <HomePage />, element: <HomePage />,
handle: {
videoSrc: 'video/nieer/nieer_lukou.mp4',
} as VideoRouteHandle,
}, },
{ {
path: 'home', path: 'home',
element: <HomePage />, element: <HomePage />,
handle: {
videoSrc: 'video/nieer/nieer_lukou.mp4',
} as VideoRouteHandle,
}, },
// 对话模块(6 个场景) // 对话模块(6 个场景)
{ {
path: 'place1', path: 'place1',
element: <NieerPlace1 />, element: <NieerPlace1 />,
handle: {
videoSrc: 'video/nieer/nieer_1.mp4',
} as VideoRouteHandle,
}, },
{ {
path: 'place2', path: 'place2',
+9
View File
@@ -21,14 +21,23 @@ export const RongdeshengRoutes: RouteObject[] = [
{ {
index: true, index: true,
element: <HomePage />, element: <HomePage />,
handle: {
videoSrc: 'video/rongdesheng/rongdesheng_lukou.mp4',
} as VideoRouteHandle,
}, },
{ {
path: 'home', path: 'home',
element: <HomePage />, element: <HomePage />,
handle: {
videoSrc: 'video/rongdesheng/rongdesheng_lukou.mp4',
} as VideoRouteHandle,
}, },
{ {
path: 'place1', path: 'place1',
element: <RongdeshengPlace1 />, element: <RongdeshengPlace1 />,
handle: {
videoSrc: 'video/rongdesheng/rongdesheng_1.mp4',
} as VideoRouteHandle,
}, },
{ {
path: 'place2', path: 'place2',
+9
View File
@@ -29,15 +29,24 @@ export const ZhanglepingRoutes: RouteObject[] = [
{ {
index: true, index: true,
element: <HomePage />, element: <HomePage />,
handle: {
videoSrc: 'video/zhangleping/zhangleping_lukou.mp4',
} as VideoRouteHandle,
}, },
{ {
path: 'home', path: 'home',
element: <HomePage />, element: <HomePage />,
handle: {
videoSrc: 'video/zhangleping/zhangleping_lukou.mp4',
} as VideoRouteHandle,
}, },
// 对话模块 // 对话模块
{ {
path: 'place1', path: 'place1',
element: <ZhanglepingPlace1 />, element: <ZhanglepingPlace1 />,
handle: {
videoSrc: 'video/zhangleping/zhangleping_1.mp4',
} as VideoRouteHandle,
}, },
{ {
path: 'place2', path: 'place2',
+6
View File
@@ -6,6 +6,9 @@ import { setUserDefine } from '@/lib/utils/api-common'
interface PuzzleStore { interface PuzzleStore {
// 每个故居的已解锁碎片集合,格式:{ residence: Set<pieceId> } // 每个故居的已解锁碎片集合,格式:{ residence: Set<pieceId> }
puzzles: Record<string, Set<number>> puzzles: Record<string, Set<number>>
isLoaded: boolean
setLoaded: (isLoaded: boolean) => void
// 解锁碎片 // 解锁碎片
unlockPuzzle: (residence: string, pieceId: number) => void unlockPuzzle: (residence: string, pieceId: number) => void
@@ -74,6 +77,9 @@ export const usePuzzleStore = create<PuzzleStore>()(
persist( persist(
(set, get) => ({ (set, get) => ({
puzzles: {}, puzzles: {},
isLoaded: false,
setLoaded: (isLoaded: boolean) => set({ isLoaded }),
unlockPuzzle: (residence: string, pieceId: number) => { unlockPuzzle: (residence: string, pieceId: number) => {
const { puzzles } = get() const { puzzles } = get()