feat: add puzzle residence configuration and reflection progress logic

- Introduced `puzzleResidences.ts` to define puzzle residence configurations including properties like display name, position, and puzzle availability.
- Added `reflectionProgress.ts` to manage badge unlocking logic based on place completion, including functions to determine unlocked badges and video sources for scenes.
This commit is contained in:
zhongyidian committed 2026-04-27 11:49:30 +08:00
1 parent e0fa47d21a
commit f5f36f75b5
153 files changed
+3846 -2352

No files matched your search

+107 -5
View File
@@ -5,6 +5,14 @@ import { filterInput } from '@/lib/utils/sensitiveWords'
import { Image, Select, Video, type VideoRef } from '@/components'
import { showHint } from '@/components/hint'
const formatCurrentDate = () => {
const now = new Date()
const year = now.getFullYear()
const month = `${now.getMonth() + 1}`.padStart(2, '0')
const day = `${now.getDate()}`.padStart(2, '0')
return `${year}.${month}.${day}`
}
export type LetterFieldType = 'text' | 'input' | 'select'
export interface LetterFieldOption {
@@ -47,6 +55,7 @@ export interface LetterTemplateProps {
audioSrc?: string
showVideo?: boolean
videoSrc?: string
replyVideoSrc?: string
sceneImageSrc?: string
replyButtonImageSrc?: string
replyImageSrc?: string
@@ -74,19 +83,26 @@ const LetterTemplate = ({
audioSrc,
showVideo = true,
videoSrc = 'video/letter/neihua_jixin.mp4',
replyVideoSrc,
sceneImageSrc = 'components/letter/动画结束后场景',
replyButtonImageSrc = 'components/letter/点击敬读',
replyImageSrc,
}: LetterTemplateProps) => {
const [blankValues, setBlankValues] = useState<string[]>([])
const [blankErrors, setBlankErrors] = useState<boolean[]>([])
const [signatureName, setSignatureName] = useState('')
const [signatureError, setSignatureError] = useState(false)
const [submitted, setSubmitted] = useState(false)
const [showSendVideo, setShowSendVideo] = useState(false)
const [showReplyVideo, setShowReplyVideo] = useState(false)
const [showScene, setShowScene] = useState(false)
const [showReplyImage, setShowReplyImage] = useState(false)
const sendVideoRef = useRef<VideoRef>(null)
const replyVideoRef = useRef<VideoRef>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
const currentDate = useMemo(() => formatCurrentDate(), [])
const resolvedFields = useMemo(
() => (typeof fields === 'function' ? fields(blankValues) : fields),
[fields, blankValues]
@@ -99,6 +115,13 @@ const LetterTemplate = ({
(_, index) => blankValues[index] ?? ''
)
const trimmedSignatureName = signatureName.trim()
if (!trimmedSignatureName) {
showHint('请填写落款姓名后再提交哦!')
setSignatureError(true)
return false
}
const nextErrors = validate
? validate(nextValues, letterContent)
: nextValues.map((value, index) => {
@@ -146,7 +169,10 @@ const LetterTemplate = ({
setSubmitted(false)
setBlankValues([])
setBlankErrors([])
setSignatureName('')
setSignatureError(false)
setShowSendVideo(false)
setShowReplyVideo(false)
setShowScene(false)
setShowReplyImage(false)
}
@@ -157,14 +183,14 @@ const LetterTemplate = ({
const audio = audioRef.current
if (!audio || !audioSrc) return
if (visible && !showSendVideo) {
if (visible && !showSendVideo && !showScene && !showReplyImage && !showReplyVideo) {
audio.currentTime = 0
audio.play().catch(() => {})
} else {
audio.pause()
audio.currentTime = 0
}
}, [visible, showSendVideo, audioSrc])
}, [visible, showSendVideo, showScene, showReplyImage, showReplyVideo, audioSrc])
useEffect(() => {
const audio = audioRef.current
@@ -252,6 +278,48 @@ const LetterTemplate = ({
}
}
const handleReplyVideoCanPlay = () => {
if (showReplyVideo && replyVideoRef.current) {
replyVideoRef.current.play().catch(() => {})
}
}
const handleSignatureNameChange = (value: string) => {
const nextValue = filterInput(value)
setSignatureName(nextValue)
const hasError = !nextValue.trim()
if (signatureError !== hasError) {
setSignatureError(hasError)
}
}
const renderSignature = (readonly: boolean) => {
if (readonly) {
return (
<div className="absolute right-[240px] bottom-[110px] z-99 flex flex-col items-end text-[26px] leading-[1.8] text-[#4e402e]">
<span>{signatureName.trim() || '  '}</span>
<span>{currentDate}</span>
</div>
)
}
return (
<div className="absolute right-[190px] bottom-[120px] z-99 flex flex-col items-end gap-2 text-[#4e402e]">
<input
name="signature-name"
className={`h-[36px] bg-transparent border-b-2 border-dashed text-center outline-none w-[120px] ${signatureError ? 'border-red-500' : 'border-[#4e402e]'}`}
style={{ fontSize: '26px' }}
autoComplete="off"
value={signatureName}
maxLength={12}
onChange={(e) => handleSignatureNameChange(e.target.value)}
/>
<span className="text-[26px] leading-[1.8]">{currentDate}</span>
</div>
)
}
const renderLine = (readonly: boolean) => {
let placeholderIndex = 0
let bodyStarted = false
@@ -354,7 +422,7 @@ const LetterTemplate = ({
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
onClick={onClose}
// onClick={onClose}
/>
)}
@@ -416,6 +484,8 @@ const LetterTemplate = ({
</div>
</div>
{renderSignature(false)}
<div className="absolute bottom-[150px] -right-30">
<img
src={getStaticImage('components/letter/笔')}
@@ -424,7 +494,7 @@ const LetterTemplate = ({
/>
</div>
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
<div className="absolute bottom-[80px] left-62 z-99">
<Image
src={getStaticImage('components/letter/可提交状态')}
alt="可提交状态"
@@ -468,7 +538,9 @@ const LetterTemplate = ({
</div>
</div>
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
{renderSignature(true)}
<div className="absolute bottom-[80px] left-50 z-99">
<Image
src={getStaticImage('components/letter/寄出')}
alt="寄出"
@@ -577,12 +649,42 @@ const LetterTemplate = ({
onClick={() => {
setShowReplyImage(false)
setShowScene(false)
if (replyVideoSrc) {
setShowReplyVideo(true)
return
}
onClose?.()
onAnimationEnd?.()
}}
/>
</motion.div>
)}
<AnimatePresence>
{showReplyVideo && replyVideoSrc && (
<motion.div
key="reply-video"
initial={{ opacity: 1 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
className="fixed inset-0 z-104 h-full w-full bg-black"
>
<Video
ref={replyVideoRef}
src={getPublicPath(replyVideoSrc)}
className="h-full w-full"
playsInline
onCanPlay={handleReplyVideoCanPlay}
onEnded={() => {
setShowReplyVideo(false)
onClose?.()
onAnimationEnd?.()
}}
/>
</motion.div>
)}
</AnimatePresence>
</>
)}
</AnimatePresence>