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:
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="停止"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
)
|
||||
|
||||
// 获取当前路由的视频路径
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -204,7 +204,7 @@ const MenuPuzzleCenter = ({
|
||||
/>
|
||||
)}
|
||||
<Image
|
||||
src={getStaticImage(`puzzle/home/导图`)}
|
||||
src={getStaticImage(`puzzle/home/追光入口`)}
|
||||
alt="导图"
|
||||
button
|
||||
onClick={(e) => {
|
||||
|
||||
Reference in new issue
Block a user