596 lines
19 KiB
JavaScript
596 lines
19 KiB
JavaScript
import React, {
|
|
useState,
|
|
useEffect,
|
|
useRef,
|
|
useCallback,
|
|
useMemo
|
|
} from 'react'
|
|
import {
|
|
useParams,
|
|
useNavigate,
|
|
Link
|
|
} from "react-router-dom";
|
|
import classes from '@/scss/operator/Operator.module.scss'
|
|
import { useConfig } from '@/state/config';
|
|
import {
|
|
useLanguage,
|
|
} from '@/state/language'
|
|
import { useHeader } from '@/state/header';
|
|
import { useAppbar } from '@/state/appbar';
|
|
import VoiceElement from '@/component/voice';
|
|
import useUmami from '@/state/insights'
|
|
import spine from '!/libs/spine-player'
|
|
import '!/libs/spine-player.css'
|
|
import Border from '@/component/border';
|
|
import { useI18n } from '@/state/language';
|
|
import Switch from '@/component/switch';
|
|
import { atom, useAtom } from 'jotai'
|
|
import BACKGROUNDS from '@/_backgrounds.json';
|
|
|
|
const musicMapping = JSON.parse(import.meta.env.VITE_MUSIC_MAPPING)
|
|
const getVoiceFoler = (lang) => {
|
|
const folderObject = JSON.parse(import.meta.env.VITE_VOICE_FOLDERS)
|
|
const voiceFolder = folderObject.sub.find(e => e.lang === lang) || folderObject.sub.find(e => e.name === 'custom')
|
|
return `${folderObject.main}/${voiceFolder.name}`
|
|
}
|
|
const defaultSpineAnimation = 'Idle'
|
|
const backgroundAtom = atom(BACKGROUNDS[0])
|
|
|
|
const getPartialName = (type, input) => {
|
|
let part;
|
|
switch (type) {
|
|
case "name":
|
|
part = 5
|
|
break;
|
|
case "skin":
|
|
part = 1
|
|
break;
|
|
default:
|
|
return input
|
|
}
|
|
return input.replace(/^(.+)( )(·|\/)( )(.+)$/, `$${part}`)
|
|
}
|
|
|
|
const getTabName = (item, language) => {
|
|
if (item.type === 'operator') {
|
|
return 'operator'
|
|
} else {
|
|
return getPartialName("skin", item.codename[language])
|
|
}
|
|
}
|
|
|
|
export default function Operator() {
|
|
const navigate = useNavigate()
|
|
const { operators } = useConfig()
|
|
const { language } = useLanguage()
|
|
const { key } = useParams();
|
|
const {
|
|
setTitle,
|
|
setTabs,
|
|
setHeaderIcon,
|
|
setFastNavigation
|
|
} = useHeader()
|
|
const { setExtraArea } = useAppbar()
|
|
const [config, setConfig] = useState(null)
|
|
const [spineData, setSpineData] = useState(null)
|
|
// eslint-disable-next-line no-unused-vars
|
|
const _trackEvt = useUmami(`/${key}`, `${key}`)
|
|
const spineRef = useRef(null)
|
|
const [spineAnimation, setSpineAnimation] = useState(defaultSpineAnimation)
|
|
const { i18n } = useI18n()
|
|
const [spinePlayer, setSpinePlayer] = useState(null)
|
|
const [voiceLang, _setVoiceLang] = useState(null)
|
|
const [currentBackground, setCurrentBackground] = useAtom(backgroundAtom)
|
|
const [voiceConfig, setVoiceConfig] = useState(null)
|
|
const [subtitleLang, setSubtitleLang] = useState(null)
|
|
const [hideSubtitle, setHideSubtitle] = useState(true)
|
|
const [subtitleObj, _setSubtitleObj] = useState(null)
|
|
const [currentVoiceId, setCurrentVoiceId] = useState(null)
|
|
const voiceLangRef = useRef(voiceLang)
|
|
const subtitleObjRef = useRef(subtitleObj)
|
|
const configRef = useRef(config)
|
|
const [voiceSrc, setVoiceSrc] = useState(null)
|
|
const [isVoicePlaying, _setIsVoicePlaying] = useState(false)
|
|
const isVoicePlayingRef = useRef(isVoicePlaying)
|
|
|
|
const setVoiceLang = (value) => {
|
|
voiceLangRef.current = value
|
|
_setVoiceLang(value)
|
|
}
|
|
|
|
const setSubtitleObj = (value) => {
|
|
subtitleObjRef.current = value
|
|
_setSubtitleObj(value)
|
|
}
|
|
|
|
const setIsVoicePlaying = (value) => {
|
|
isVoicePlayingRef.current = value
|
|
_setIsVoicePlaying(value)
|
|
}
|
|
|
|
useEffect(() => {
|
|
setExtraArea([])
|
|
setFastNavigation([])
|
|
}, [setExtraArea, setFastNavigation])
|
|
|
|
useEffect(() => {
|
|
setSpineData(null)
|
|
const config = operators.find((item) => item.link === key)
|
|
if (config) {
|
|
setConfig(config)
|
|
configRef.current = config
|
|
fetch(`/${import.meta.env.VITE_DIRECTORY_FOLDER}/${config.filename.replace("#", "%23")}.json`).then(res => res.json()).then(data => {
|
|
setSpineAnimation(defaultSpineAnimation)
|
|
setSpineData(data)
|
|
})
|
|
setHeaderIcon(config.type)
|
|
if (spineRef.current?.children.length > 0) {
|
|
spineRef.current?.removeChild(spineRef.current?.children[0])
|
|
}
|
|
fetch(`/${import.meta.env.VITE_DIRECTORY_FOLDER}/voice_${config.link}.json`).then(res => res.json()).then(data => {
|
|
setVoiceConfig(data)
|
|
})
|
|
}
|
|
}, [key, operators, setHeaderIcon])
|
|
|
|
const coverToTab = useCallback((item, language) => {
|
|
const key = getTabName(item, language)
|
|
return {
|
|
key: key,
|
|
style: {
|
|
color: item.color
|
|
},
|
|
onClick: (e, tab) => {
|
|
if (tab === key) return
|
|
navigate(`/${item.link}`)
|
|
}
|
|
}
|
|
}, [navigate])
|
|
|
|
const otherEntries = useMemo(() => {
|
|
if (!config || !language) return null
|
|
return operators.filter((item) => item.id === config.id && item.link !== config.link).map((item) => {
|
|
return coverToTab(item, language)
|
|
})
|
|
}, [config, language, operators, coverToTab])
|
|
|
|
useEffect(() => {
|
|
if (config) {
|
|
setTabs(
|
|
[
|
|
coverToTab(config, language),
|
|
...otherEntries
|
|
]
|
|
)
|
|
}
|
|
}, [config, key, coverToTab, setTabs, otherEntries, language])
|
|
|
|
useEffect(() => {
|
|
if (config) {
|
|
setTitle(getPartialName("name", config.codename[language]))
|
|
}
|
|
}, [config, language, key, setTitle])
|
|
|
|
useEffect(() => {
|
|
if (spineRef.current?.children.length === 0 && spineData && config) {
|
|
const playerConfig = {
|
|
skelUrl: `./assets/${config.filename.replace('#', '%23')}.skel`,
|
|
atlasUrl: `./assets/${config.filename.replace('#', '%23')}.atlas`,
|
|
rawDataURIs: spineData,
|
|
animation: spineAnimation,
|
|
premultipliedAlpha: true,
|
|
alpha: true,
|
|
backgroundColor: "#00000000",
|
|
viewport: {
|
|
debugRender: false,
|
|
padLeft: `${config.viewport_left}%`,
|
|
padRight: `${config.viewport_right}%`,
|
|
padTop: `${config.viewport_top}%`,
|
|
padBottom: `${config.viewport_bottom}%`,
|
|
x: 0,
|
|
y: 0,
|
|
},
|
|
showControls: false,
|
|
touch: false,
|
|
fps: 60,
|
|
defaultMix: 0.3,
|
|
success: (player) => {
|
|
if (player.skeleton.data.animations.map(e => e.name).includes("Start")) {
|
|
player.animationState.setAnimation(0, "Start", false, 0)
|
|
player.animationState.addAnimation(0, "Idle", true, 0);
|
|
}
|
|
let lastVoiceId = null
|
|
let currentVoiceId = null
|
|
player.canvas.onclick = () => {
|
|
if (!voiceLangRef.current) return
|
|
const voiceId = () => {
|
|
const keys = Object.keys(subtitleObjRef.current)
|
|
const id = keys[Math.floor((Math.random() * keys.length))]
|
|
return id === lastVoiceId ? voiceId() : id
|
|
}
|
|
const id = voiceId()
|
|
currentVoiceId = id
|
|
setCurrentVoiceId(id)
|
|
setVoiceSrc(`/${configRef.current.link}/assets/${getVoiceFoler(voiceLangRef.current)}/${id}.ogg`)
|
|
lastVoiceId = currentVoiceId
|
|
}
|
|
}
|
|
}
|
|
|
|
if (config.use_json) {
|
|
playerConfig.jsonUrl = `./assets/${config.filename.replace('#', '%23')}.json`;
|
|
} else {
|
|
playerConfig.skelUrl = `./assets/${config.filename.replace('#', '%23')}.skel`;
|
|
}
|
|
|
|
setSpinePlayer(new spine.SpinePlayer(spineRef.current, playerConfig))
|
|
}
|
|
}, [config, spineData, spineAnimation]);
|
|
|
|
useEffect(() => {
|
|
if (voiceConfig && voiceLang) {
|
|
let subtitleObj = voiceConfig.subtitleLangs[subtitleLang || 'zh-CN']
|
|
let subtitleKey = 'default'
|
|
if (subtitleObj[voiceLang]) {
|
|
subtitleKey = voiceLang
|
|
}
|
|
setSubtitleObj(subtitleObj[subtitleKey])
|
|
}
|
|
}, [subtitleLang, voiceConfig, voiceLang])
|
|
|
|
const handleAduioStateChange = useCallback((e, state) => {
|
|
switch (state) {
|
|
case 'play':
|
|
setIsVoicePlaying(true)
|
|
break
|
|
default:
|
|
setIsVoicePlaying(false)
|
|
break
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (subtitleLang) {
|
|
if (isVoicePlaying) {
|
|
setHideSubtitle(false)
|
|
} else {
|
|
const autoHide = () => {
|
|
if (isVoicePlayingRef.current) return
|
|
setHideSubtitle(true)
|
|
}
|
|
setTimeout(autoHide, 5 * 1000)
|
|
return () => {
|
|
clearTimeout(autoHide)
|
|
}
|
|
}
|
|
} else {
|
|
setHideSubtitle(true)
|
|
}
|
|
}, [subtitleLang, isVoicePlaying])
|
|
|
|
useEffect(() => {
|
|
if (voiceLang && isVoicePlaying) {
|
|
const audioUrl = `/assets/${getVoiceFoler(voiceLang)}/${currentVoiceId}.ogg`
|
|
if (voiceSrc !== (window.location.href.replace(/\/$/g, '') + audioUrl)) {
|
|
setVoiceSrc(`/${config.link}${audioUrl}`)
|
|
}
|
|
}
|
|
}, [voiceLang, isVoicePlaying, currentVoiceId, config, voiceSrc])
|
|
|
|
const playAnimationVoice = useCallback((animation) => {
|
|
if (voiceLangRef.current) {
|
|
let id = null
|
|
if (animation === 'Idle') id = 'CN_011'
|
|
if (animation === 'Interact') id = 'CN_034'
|
|
if (animation === 'Special') id = 'CN_042'
|
|
if (id) {
|
|
setCurrentVoiceId(id)
|
|
setVoiceSrc(`/${key}/assets/${getVoiceFoler(voiceLangRef.current)}/${id}.ogg`)
|
|
}
|
|
}
|
|
}, [key])
|
|
|
|
useEffect(() => {
|
|
if (!voiceLang) {
|
|
setVoiceSrc(null)
|
|
}
|
|
}, [voiceLang])
|
|
|
|
const spineSettings = [
|
|
{
|
|
name: 'animation',
|
|
options: [
|
|
{
|
|
name: 'idle',
|
|
onClick: () => {
|
|
const animation = "Idle"
|
|
playAnimationVoice(animation)
|
|
spinePlayer.animationState.setAnimation(0, animation, true, 0)
|
|
setSpineAnimation(animation)
|
|
},
|
|
activeRule: () => {
|
|
return spineAnimation === 'Idle'
|
|
}
|
|
}, {
|
|
name: 'interact',
|
|
onClick: () => {
|
|
const animation = "Interact"
|
|
playAnimationVoice(animation)
|
|
spinePlayer.animationState.setAnimation(0, animation, true, 0)
|
|
setSpineAnimation(animation)
|
|
},
|
|
activeRule: () => {
|
|
return spineAnimation === 'Interact'
|
|
}
|
|
}, {
|
|
name: 'special',
|
|
onClick: () => {
|
|
const animation = "Special"
|
|
playAnimationVoice(animation)
|
|
spinePlayer.animationState.setAnimation(0, animation, true, 0)
|
|
setSpineAnimation(animation)
|
|
},
|
|
activeRule: () => {
|
|
return spineAnimation === 'Special'
|
|
}
|
|
}
|
|
]
|
|
}, {
|
|
name: 'voice',
|
|
options: voiceConfig && Object.keys(voiceConfig?.voiceLangs["zh-CN"]).map((item) => {
|
|
return {
|
|
name: i18n(item),
|
|
onClick: () => {
|
|
if (voiceLang !== item) {
|
|
setVoiceLang(item)
|
|
} else {
|
|
setVoiceLang(null)
|
|
}
|
|
if (!isVoicePlayingRef.current) {
|
|
playAnimationVoice(spineAnimation)
|
|
}
|
|
},
|
|
activeRule: () => {
|
|
return voiceLang === item
|
|
}
|
|
}
|
|
}) || []
|
|
}, {
|
|
name: 'subtitle',
|
|
options: voiceConfig && Object.keys(voiceConfig?.subtitleLangs).map((item) => {
|
|
return {
|
|
name: i18n(item),
|
|
onClick: () => {
|
|
if (subtitleLang !== item) {
|
|
setSubtitleLang(item)
|
|
} else {
|
|
setSubtitleLang(null)
|
|
}
|
|
},
|
|
activeRule: () => {
|
|
return subtitleLang === item
|
|
}
|
|
}
|
|
}) || []
|
|
}, {
|
|
name: 'music',
|
|
el: <MusicElement />
|
|
}, {
|
|
name: 'backgrounds',
|
|
options: BACKGROUNDS.map((item) => {
|
|
return {
|
|
name: item,
|
|
onClick: () => {
|
|
setCurrentBackground(item)
|
|
},
|
|
activeRule: () => {
|
|
return currentBackground === item
|
|
}
|
|
}
|
|
}) || []
|
|
}
|
|
]
|
|
|
|
if (!JSON.parse(import.meta.env.VITE_AVAILABLE_OPERATORS).includes(key)) {
|
|
throw new Error('Operator not found')
|
|
}
|
|
|
|
return (
|
|
<section className={classes.operator}>
|
|
<section className={classes.main}>
|
|
<section className={classes.settings} style={{
|
|
color: config?.color
|
|
}}>
|
|
{
|
|
spineSettings.map((item) => {
|
|
if (item.el) {
|
|
return (
|
|
<section key={item.name}>
|
|
{item.el}
|
|
</section>
|
|
)
|
|
}
|
|
if (item.options.length === 0) return null
|
|
return (
|
|
<section key={item.name}>
|
|
<section className={classes.title}>
|
|
<section className={classes.text}>{i18n(item.name)}</section>
|
|
</section>
|
|
<section className={classes['styled-selection']}>
|
|
{item.options.map((option) => {
|
|
return (
|
|
<section className={`${classes.content} ${option.activeRule && option.activeRule() ? classes.active : ''}`} onClick={(e) => option.onClick(e)} key={option.name}>
|
|
<section className={classes.option}>
|
|
<section className={classes.outline} />
|
|
<section className={`${classes.text} ${classes['no-overflow']}`}>{i18n(option.name)}</section>
|
|
<section className={classes['tick-icon']} />
|
|
</section>
|
|
</section>
|
|
)
|
|
})}
|
|
</section>
|
|
</section>
|
|
)
|
|
})
|
|
}
|
|
<section>
|
|
<section className={classes.title}>
|
|
<section className={classes.text}>{i18n('external_links')}</section>
|
|
</section>
|
|
<section className={classes['styled-selection']}>
|
|
<Link
|
|
reloadDocument
|
|
to={`./index.html?settings`}
|
|
target='_blank'
|
|
style={{
|
|
color: config?.color
|
|
}}
|
|
>
|
|
<section className={classes.content}>
|
|
<section className={classes.option}>
|
|
<section className={classes.outline} />
|
|
<section className={`${classes.text} ${classes['no-overflow']}`}>
|
|
{i18n('web_version')}
|
|
</section>
|
|
<section className={classes['arrow-icon']}>
|
|
<section className={classes.bar}></section>
|
|
<section className={classes.bar}></section>
|
|
<section className={classes.bar}></section>
|
|
<section className={classes.bar}></section>
|
|
</section>
|
|
</section>
|
|
</section>
|
|
</Link>
|
|
{
|
|
config?.workshopId && (
|
|
<Link
|
|
reloadDocument
|
|
to={`https://steamcommunity.com/sharedfiles/filedetails/?id=${config.workshopId}`}
|
|
target='_blank'
|
|
style={{
|
|
color: config?.color
|
|
}}>
|
|
<section className={classes.content}>
|
|
<section className={classes.option}>
|
|
<section className={classes.outline} />
|
|
<section className={`${classes.text} ${classes['no-overflow']}`}>
|
|
{i18n('steam_workshop')}
|
|
</section>
|
|
<section className={classes['arrow-icon']}>
|
|
<section className={classes.bar}></section>
|
|
<section className={classes.bar}></section>
|
|
<section className={classes.bar}></section>
|
|
<section className={classes.bar}></section>
|
|
</section>
|
|
</section>
|
|
</section>
|
|
</Link>
|
|
)
|
|
}
|
|
</section>
|
|
</section>
|
|
</section>
|
|
<section className={classes.container} style={currentBackground && {
|
|
backgroundImage: `url(/chen/assets/${import.meta.env.VITE_BACKGROUND_FOLDER}/${currentBackground})`
|
|
}} >
|
|
{
|
|
config && (
|
|
<img
|
|
src={`/${config.link}/assets/${config.logo}.png`}
|
|
alt={config?.codename[language]}
|
|
className={classes.logo}
|
|
style={config.invert_filter ? {
|
|
filter: "invert(1)"
|
|
} : {}} />
|
|
)
|
|
}
|
|
<section ref={spineRef} className={classes.wrapper} />
|
|
{currentVoiceId && subtitleObj && (
|
|
<section className={`${classes.voice} ${hideSubtitle ? '' : classes.active}`}>
|
|
<section className={classes.type}>{subtitleObj[currentVoiceId]?.title}</section>
|
|
<section className={classes.subtitle}>
|
|
<span>{subtitleObj[currentVoiceId]?.text}</span>
|
|
<span className={classes.triangle} />
|
|
</section>
|
|
</section>)
|
|
}
|
|
</section>
|
|
</section>
|
|
<Border />
|
|
<VoiceElement
|
|
src={voiceSrc}
|
|
handleAduioStateChange={handleAduioStateChange}
|
|
/>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function MusicElement() {
|
|
const [enableMusic, setEnableMusic] = useState(false)
|
|
const { i18n } = useI18n()
|
|
const musicIntroRef = useRef(null)
|
|
const musicLoopRef = useRef(null)
|
|
const [background,] = useAtom(backgroundAtom)
|
|
|
|
useEffect(() => {
|
|
if (musicIntroRef.current && musicIntroRef.current) {
|
|
musicIntroRef.current.volume = 0.5
|
|
musicLoopRef.current.volume = 0.5
|
|
}
|
|
}, [musicIntroRef, musicLoopRef])
|
|
|
|
useEffect(() => {
|
|
if (!enableMusic || background) {
|
|
musicIntroRef.current.pause()
|
|
musicLoopRef.current.pause()
|
|
}
|
|
}, [enableMusic, background])
|
|
|
|
useEffect(() => {
|
|
if (background && enableMusic) {
|
|
const introOgg = musicMapping[background].intro
|
|
const intro = `./chen/assets/${import.meta.env.VITE_MUSIC_FOLDER}/${introOgg}`
|
|
const loop = `./chen/assets/${import.meta.env.VITE_MUSIC_FOLDER}/${musicMapping[background].loop}`
|
|
musicLoopRef.current.src = loop
|
|
if (introOgg) {
|
|
musicIntroRef.current.src = intro || loop
|
|
} else {
|
|
musicLoopRef.current.play()
|
|
}
|
|
}
|
|
}, [background, enableMusic])
|
|
|
|
const handleIntroTimeUpdate = useCallback(() => {
|
|
if (musicIntroRef.current.currentTime >= musicIntroRef.current.duration - 0.3) {
|
|
musicIntroRef.current.pause()
|
|
musicLoopRef.current.play()
|
|
}
|
|
}, [])
|
|
|
|
const handleLoopTimeUpdate = useCallback(() => {
|
|
if (musicLoopRef.current.currentTime >= musicLoopRef.current.duration - 0.3) {
|
|
musicLoopRef.current.currentTime = 0
|
|
musicLoopRef.current.play()
|
|
}
|
|
}, [])
|
|
|
|
return (
|
|
<section>
|
|
<section
|
|
className={classes.title}
|
|
onClick={() => setEnableMusic(!enableMusic)}
|
|
>
|
|
<section className={classes.text}>{i18n('music')}</section>
|
|
<section className={classes.switch}>
|
|
<Switch on={enableMusic} />
|
|
</section>
|
|
</section>
|
|
<audio ref={musicIntroRef} preload="auto" autoPlay onTimeUpdate={() => handleIntroTimeUpdate()}>
|
|
<source type="audio/ogg" />
|
|
</audio>
|
|
<audio ref={musicLoopRef} preload="auto" onTimeUpdate={() => handleLoopTimeUpdate()}>
|
|
<source type="audio/ogg"/>
|
|
</audio>
|
|
</section>
|
|
)
|
|
} |