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

@@ -1,4 +1,4 @@
import { useEffect } from 'react'
import { useEffect, useRef } from 'react'
import { cn, getBackgroundImage } from '@/lib/utils'
import { AnimatePresence, motion } from 'framer-motion'
import { useAudioManager } from '@/hooks/useAudioManager'
@@ -40,19 +40,35 @@ const ExhibitDescriptionNarration = ({
// 当前组件对应的音频是否正在播放
const isCurrentAudioPlaying = audioSrc && currentSrc === audioSrc && isPlaying
// 标记本实例是否发起过播放,用于在不可见/卸载时可靠停止
// 不依赖 currentSrc,避免 audio.play() Promise 未 resolve 前关闭弹窗导致漏停
const playInitiatedRef = useRef(false)
// 自动播放音频
useEffect(() => {
if (visible && autoPlay && audioSrc) {
playInitiatedRef.current = true
play(audioSrc)
}
}, [visible, autoPlay, audioSrc, play])
// 组件不可见时停止音频(仅当当前播放的是该组件的 audioSrc)
// 组件不可见时停止音频
useEffect(() => {
if (!visible && audioSrc && currentSrc === audioSrc) {
if (!visible && playInitiatedRef.current) {
stop()
playInitiatedRef.current = false
}
}, [visible, audioSrc, currentSrc, stop])
}, [visible, stop])
// 组件卸载时兜底停止
useEffect(() => {
return () => {
if (playInitiatedRef.current) {
stop()
playInitiatedRef.current = false
}
}
}, [stop])
if (!text) return null
@@ -98,7 +114,10 @@ const ExhibitDescriptionNarration = ({
<div className="flex items-center justify-end gap-2 flex-shrink-0">
{!isCurrentAudioPlaying && (
<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"
aria-label="播放"
>
@@ -109,7 +128,10 @@ const ExhibitDescriptionNarration = ({
)}
{isCurrentAudioPlaying && (
<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"
aria-label="停止"
>
+35 -14
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from 'react'
import { useEffect, useState } from 'react'
import type { CSSProperties, ReactNode } from 'react'
import { Children, useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { Image } from '@/components'
import { cn, getStaticImage } from '@/lib/utils'
@@ -7,6 +7,35 @@ import { useScale } from '@/contexts/ScaleContext'
import { useAudioManager } from '@/hooks/useAudioManager'
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 {
/** 是否可见 */
visible?: boolean
@@ -120,6 +149,8 @@ export const StudyGuideModal = ({
// }
}
const contentChildren = renderIndentedContent(children, firstLineIndent)
const content = (
<div
className="fixed inset-0 flex items-center justify-center"
@@ -158,21 +189,11 @@ export const StudyGuideModal = ({
)}
style={{
marginTop: verticalAlign === 'center' ? '85px' : '',
textIndent:
firstLineIndent === undefined
? undefined
: typeof firstLineIndent === 'number'
? `${firstLineIndent}px`
: firstLineIndent,
paddingLeft:
contentPaddingLeft === undefined
? undefined
: typeof contentPaddingLeft === 'number'
? `${contentPaddingLeft}px`
: contentPaddingLeft,
contentPaddingLeft === undefined ? undefined : toCssSize(contentPaddingLeft),
}}
>
{children}
{contentChildren}
</div>
)}
</div>
+26 -4
View File
@@ -136,10 +136,17 @@ const ResidenceLayoutContent = () => {
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(
async (pieceId: number, retryCount = 0): Promise<void> => {
@@ -165,7 +172,15 @@ const ResidenceLayoutContent = () => {
unlockPuzzle(residence, pieceId)
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
className="relative w-[1200px] h-full pointer-events-auto"
onClick={(e) => e.stopPropagation()}
style={{ perspective: '1200px' }}
style={{
perspective: '1200px',
WebkitPerspective: '1200px',
}}
>
<motion.div
className="relative w-full h-full"
initial={false}
animate={{ rotateY: submitted ? 180 : 0 }}
transition={{ duration: 0.7, ease: 'easeInOut' }}
style={{ transformStyle: 'preserve-3d' }}
style={{
transformStyle: 'preserve-3d',
WebkitTransformStyle: 'preserve-3d',
}}
>
<div
className="absolute inset-0"
style={{
backfaceVisibility: 'hidden',
WebkitBackfaceVisibility: 'hidden',
transform: 'translateZ(0)',
WebkitTransform: 'translateZ(0)',
pointerEvents: submitted ? 'none' : 'auto',
}}
>
@@ -510,8 +519,10 @@ const LetterTemplate = ({
<div
className="absolute inset-0"
style={{
transform: 'rotateY(180deg)',
transform: 'rotateY(180deg) translateZ(0)',
WebkitTransform: 'rotateY(180deg) translateZ(0)',
backfaceVisibility: 'hidden',
WebkitBackfaceVisibility: 'hidden',
pointerEvents: submitted ? 'auto' : 'none',
}}
>
+1 -1
View File
@@ -204,7 +204,7 @@ const MenuPuzzleCenter = ({
/>
)}
<Image
src={getStaticImage(`puzzle/home/导图`)}
src={getStaticImage(`puzzle/home/追光入口`)}
alt="导图"
button
onClick={(e) => {