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