refactor: control character from scene (#58551)

This commit is contained in:
Oliver Eyton-Williams
2025-02-03 21:12:31 +01:00
committed by GitHub
parent 1948c3ef73
commit 25c964abc0
4 changed files with 61 additions and 30 deletions
@@ -3,13 +3,14 @@ import { Characters, CharacterPosition } from '../../../../redux/prop-types';
import { characterAssets } from './scene-assets'; import { characterAssets } from './scene-assets';
import './character.css'; import './character.css';
import { SceneSubject } from './scene-subject';
interface CharacterProps { interface CharacterProps {
position: CharacterPosition; position: CharacterPosition;
opacity: number; opacity: number;
name: Characters; name: Characters;
isBlinking: boolean;
isTalking: boolean; isTalking: boolean;
sceneSubject: SceneSubject;
} }
interface CharacterStyles { interface CharacterStyles {
@@ -27,29 +28,43 @@ export function Character({
position, position,
opacity, opacity,
name, name,
isBlinking, isTalking,
isTalking sceneSubject
}: CharacterProps): JSX.Element { }: CharacterProps): JSX.Element {
const [eyesAreOpen, setEyesAreOpen] = useState(true); const [eyesAreOpen, setEyesAreOpen] = useState(true);
const [mouthIsOpen, setMouthIsOpen] = useState(false); const [mouthIsOpen, setMouthIsOpen] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const onNotify = (eventType: 'play' | 'stop') => {
if (eventType === 'play') {
setIsPlaying(true);
} else {
setIsPlaying(false);
}
};
useEffect(() => { useEffect(() => {
let blinkIntervalId: NodeJS.Timeout; sceneSubject.attach(onNotify);
return () => {
sceneSubject.detach(onNotify);
};
}, [sceneSubject]);
useEffect(() => {
if (!isPlaying) return;
let blinkTimeoutId: NodeJS.Timeout; let blinkTimeoutId: NodeJS.Timeout;
if (isBlinking) { const blinkPeriod = getRandomInt(2000, 5000);
const blinkPeriod = getRandomInt(2000, 5000); const blinkIntervalId = setInterval(() => {
blinkIntervalId = setInterval(() => { const blinkJitter = getRandomInt(0, 1000);
const blinkJitter = getRandomInt(0, 1000); blinkTimeoutId = setTimeout(() => {
blinkTimeoutId = setTimeout(() => { setEyesAreOpen(false);
setEyesAreOpen(false);
blinkTimeoutId = setTimeout(() => { blinkTimeoutId = setTimeout(() => {
setEyesAreOpen(true); setEyesAreOpen(true);
}, 30); // always unblink after 30ms }, 30); // always unblink after 30ms
}, blinkJitter); }, blinkJitter);
}, blinkPeriod); }, blinkPeriod);
}
// Clear intervals when component is unmounted or conditions change // Clear intervals when component is unmounted or conditions change
return () => { return () => {
@@ -57,9 +72,10 @@ export function Character({
clearInterval(blinkIntervalId); clearInterval(blinkIntervalId);
clearTimeout(blinkTimeoutId); clearTimeout(blinkTimeoutId);
}; };
}, [isBlinking]); }, [isPlaying]);
useEffect(() => { useEffect(() => {
if (!isPlaying) return;
let talkIntervalId: NodeJS.Timeout; let talkIntervalId: NodeJS.Timeout;
let mouthOpenTimeoutId: NodeJS.Timeout; let mouthOpenTimeoutId: NodeJS.Timeout;
let mouthCloseTimeoutId: NodeJS.Timeout; let mouthCloseTimeoutId: NodeJS.Timeout;
@@ -91,7 +107,7 @@ export function Character({
clearTimeout(mouthOpenTimeoutId); clearTimeout(mouthOpenTimeoutId);
clearTimeout(mouthCloseTimeoutId); clearTimeout(mouthCloseTimeoutId);
}; };
}, [isTalking]); }, [isTalking, isPlaying]);
const characterWrapStyles: CharacterStyles = { const characterWrapStyles: CharacterStyles = {
opacity opacity
@@ -1,4 +1,4 @@
type Observer = () => void; type Observer = (eventType: 'play' | 'stop') => void;
export class SceneSubject { export class SceneSubject {
#observers: Observer[]; #observers: Observer[];
@@ -16,7 +16,7 @@ export class SceneSubject {
// For now, we don't need to pass any data to the observers, so notify() // For now, we don't need to pass any data to the observers, so notify()
// doesn't take any arguments. // doesn't take any arguments.
notify() { notify(eventType: 'play' | 'stop') {
this.#observers.forEach(observer => observer()); this.#observers.forEach(observer => observer(eventType));
} }
} }
@@ -153,7 +153,7 @@ export function Scene({
canPauseRef.current = false; canPauseRef.current = false;
}; };
const playScene = useCallback(() => { const handlePlay = useCallback(() => {
const updateCurrentTime = () => { const updateCurrentTime = () => {
const time = Date.now() - startRef.current; const time = Date.now() - startRef.current;
setCurrentTime(time); setCurrentTime(time);
@@ -207,9 +207,9 @@ export function Scene({
}, },
duration + sToMs(audio.startTime) duration + sToMs(audio.startTime)
); );
}, [isPlaying, sceneIsReady, audio, duration]); }, [audio, duration, isPlaying, sceneIsReady]);
const resetScene = useCallback(() => { const handleStop = useCallback(() => {
usedCommandsRef.current.clear(); usedCommandsRef.current.clear();
pause(); pause();
audioRef.current.currentTime = audio.startTimestamp || 0; audioRef.current.currentTime = audio.startTimestamp || 0;
@@ -222,12 +222,27 @@ export function Scene({
setBackground(initBackground); setBackground(initBackground);
}, [audio, initCharacters, initBackground]); }, [audio, initCharacters, initBackground]);
const onNotify = useCallback(
(eventType: 'play' | 'stop') => {
if (eventType === 'play') {
handlePlay();
} else {
handleStop();
}
},
[handlePlay, handleStop]
);
const resetScene = useCallback(() => {
sceneSubject.notify('stop');
}, [sceneSubject]);
useEffect(() => { useEffect(() => {
sceneSubject.attach(playScene); sceneSubject.attach(onNotify);
return () => { return () => {
sceneSubject.detach(playScene); sceneSubject.detach(onNotify);
}; };
}, [playScene, sceneSubject]); }, [onNotify, sceneSubject]);
useEffect(() => { useEffect(() => {
if (isEmpty(sortedCommands)) return; if (isEmpty(sortedCommands)) return;
@@ -306,8 +321,8 @@ export function Scene({
name={character} name={character}
position={position} position={position}
opacity={opacity} opacity={opacity}
sceneSubject={sceneSubject}
isTalking={isTalking} isTalking={isTalking}
isBlinking={isPlaying}
/> />
); );
} }
@@ -328,7 +343,7 @@ export function Scene({
<div className='scene-start-screen'> <div className='scene-start-screen'>
<button <button
className='scene-start-btn scene-play-btn' className='scene-start-btn scene-play-btn'
onClick={() => sceneSubject.notify()} onClick={() => sceneSubject.notify('play')}
> >
<img <img
src={`${images}/play-button.png`} src={`${images}/play-button.png`}
@@ -198,7 +198,7 @@ const ShowGeneric = ({
<Hotkeys <Hotkeys
executeChallenge={handleSubmit} executeChallenge={handleSubmit}
containerRef={container} containerRef={container}
playScene={scene ? () => sceneSubject.notify() : undefined} playScene={scene ? () => sceneSubject.notify('play') : undefined}
> >
<LearnLayout> <LearnLayout>
<Helmet <Helmet