From fb79d5b114ca8edc5d8f8afc01ced2afc9901d87 Mon Sep 17 00:00:00 2001 From: Erik Onarheim Date: Sun, 5 Jun 2022 10:57:26 -0500 Subject: [PATCH] feat: Add missing Sound features `.seek()`, `.duration`, & `getTotalPlaybackDuration()` (#2340) This PR adds the ability to `ex.Sound.seek()` to a specific position in the sound, it also adds the `ex.Sound.getTotalPlaybackDuration()` so you may know how long in seconds the sound file is. Lastly `ex.Sound.duration` if set will limit the duration of the clip from where played(); In order to accomplish this, `ex.Sound()` was refactored using a small `ex.StateMachine` implementation Example ```typescript const machine = ex.StateMachine.create({ start: 'STOPPED', states: { PLAYING: { onEnter: () => { console.log("playing"); }, transitions: ['STOPPED', 'PAUSED'] }, STOPPED: { onEnter: () => { console.log("stopped"); }, transitions: ['PLAYING'] }, PAUSED: { onEnter: () => { console.log("paused") }, transitions: ['PLAYING', 'STOPPED'] } } }); ``` ## Changes: - Adds `ex.Sound.seek()` - Adds `ex.Sound.duration` - Adds `ex.Sound.getTotalPlaybackDuration()` - Adds new `ex.StateMachine()` --- CHANGELOG.md | 35 ++- sandbox/tests/sound/sound.ts | 34 ++- src/engine/Interfaces/Audio.ts | 16 + src/engine/Loader.ts | 8 + src/engine/Resources/Sound/Sound.ts | 50 +++- .../Resources/Sound/WebAudioInstance.ts | 273 ++++++++---------- src/engine/Util/StateMachine.ts | 112 +++++++ src/engine/index.ts | 1 + src/spec/SoundSpec.ts | 47 ++- src/spec/StateMachineSpec.ts | 207 +++++++++++++ 10 files changed, 616 insertions(+), 167 deletions(-) create mode 100644 src/engine/Util/StateMachine.ts create mode 100644 src/spec/StateMachineSpec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 55909de7..56ea85f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,38 @@ This project adheres to [Semantic Versioning](http://semver.org/). - ### Added - +- Allowed setting playback `ex.Sound.duration` which will limit the amount of time that a clip plays from the current playback position. +- Added a new lightweight `ex.StateMachine` type for building finite state machines + ```typescript + const machine = ex.StateMachine.create({ + start: 'STOPPED', + states: { + PLAYING: { + onEnter: () => { + console.log("playing"); + }, + transitions: ['STOPPED', 'PAUSED'] + }, + STOPPED: { + onEnter: () => { + console.log("stopped"); + }, + transitions: ['PLAYING', 'SEEK'] + }, + SEEK: { + transitions: ['*'] + }, + PAUSED: { + onEnter: () => { + console.log("paused") + }, + transitions: ['PLAYING', 'STOPPED'] + } + } + }); + ``` +- Added `ex.Sound.seek(positionInSeconds)` which will allow you to see to a place in the sound, this will implicitly pause the sound +- Added `ex.Sound.getTotalPlaybackDuration()` which will return the total time in the sound in seconds. - Allow tinting of `ex.Sprite`'s by setting a new `tint` property, renderers must support the tint property in order to function. ```typescript const imageSource = new ex.ImageSource('./path/to/image.png'); @@ -39,7 +70,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Fixed -- +- Fixed issue where `ex.Sound` wasn't being paused when the browser window lost focus ### Updates diff --git a/sandbox/tests/sound/sound.ts b/sandbox/tests/sound/sound.ts index 338d4eb2..b35bf46d 100644 --- a/sandbox/tests/sound/sound.ts +++ b/sandbox/tests/sound/sound.ts @@ -10,16 +10,27 @@ var loader = new ex.Loader([ sound ]); -var label = new ex.Label({ +var currentTimeLabel = new ex.Label({ x: 50, y: 100, text: 'Current Time: 0' }); -label.font = new ex.Font({ +currentTimeLabel.font = new ex.Font({ size: 30, family: 'sans-serif' }); -game.currentScene.add(label); +game.currentScene.add(currentTimeLabel); + +var totalTimeLabel = new ex.Label({ + x: 350, + y: 100, + text: 'Total Time: 0' +}); +totalTimeLabel.font = new ex.Font({ + size: 30, + family: 'sans-serif' +}); +game.currentScene.add(totalTimeLabel); var play = new ex.Actor({ x: 200, @@ -45,9 +56,24 @@ pause.on('pointerdown', () => { }); game.currentScene.add(pause); +var pause = new ex.Actor({ + x: 400, + y: 200, + width: 40, + height: 40, + color: ex.Color.Yellow +}); +pause.on('pointerdown', () => { + sound.duration = 2; + sound.seek(5); + sound.play(); +}); +game.currentScene.add(pause); + game.currentScene.onPostUpdate = () => { - label.text = 'Current Time: ' + sound.getPlaybackPosition(); + currentTimeLabel.text = 'Current Time: ' + sound.getPlaybackPosition().toFixed(2); + totalTimeLabel.text = 'Total Time: ' + sound.getTotalPlaybackDuration().toFixed(2); } game.start(loader); \ No newline at end of file diff --git a/src/engine/Interfaces/Audio.ts b/src/engine/Interfaces/Audio.ts index 0362161d..8bf93331 100644 --- a/src/engine/Interfaces/Audio.ts +++ b/src/engine/Interfaces/Audio.ts @@ -23,11 +23,27 @@ export interface Audio { */ isPlaying(): boolean; + /** + * Returns if the audio is paused + */ + isPaused(): boolean; + /** * Will play the sound or resume if paused */ play(): Promise; + /** + * Seek to a position (in seconds) in the audio + * @param position + */ + seek(position: number): void; + + /** + * Return the duration of the sound + */ + getTotalPlaybackDuration(): number; + /** * Return the current playback time of the playing track in seconds from the start */ diff --git a/src/engine/Loader.ts b/src/engine/Loader.ts index 67ddc688..ac4418c8 100644 --- a/src/engine/Loader.ts +++ b/src/engine/Loader.ts @@ -12,6 +12,7 @@ import { Vector } from './Math/vector'; import { delay } from './Util/Util'; import { ImageFiltering } from './Graphics/Filtering'; import { clamp } from './Math/util'; +import { Sound } from './Resources/Sound/Sound'; /** * Pre-loading assets @@ -333,6 +334,13 @@ export class Loader extends Class implements Loadable[]> { }) ) ); + // Wire all sound to the engine + for (const resource of this._resourceList) { + if (resource instanceof Sound) { + resource.wireEngine(this._engine); + } + } + this._isLoadedResolve(); // short delay in showing the button for aesthetics diff --git a/src/engine/Resources/Sound/Sound.ts b/src/engine/Resources/Sound/Sound.ts index 160f1eda..502b88ec 100644 --- a/src/engine/Resources/Sound/Sound.ts +++ b/src/engine/Resources/Sound/Sound.ts @@ -51,9 +51,23 @@ export class Sound extends Class implements Audio, Loadable { return this._volume; } + private _duration: number | undefined; + /** + * Get the duration that this audio should play. If unset the total natural playback duration will be used. + */ public get duration(): number | undefined { return this._duration; } + /** + * Set the duration that this audio should play. If unset the total natural playback duration will be used. + * + * Note: if you seek to a specific point the duration will start from that point, for example + * + * If you have a 10 second clip, seek to 5 seconds, then set the duration to 2, it will play the clip from 5-7 seconds. + */ + public set duration(duration: number | undefined){ + this._duration = duration; + } /** * Return array of Current AudioInstances playing or being paused @@ -72,9 +86,8 @@ export class Sound extends Class implements Audio, Loadable { private _loop = false; private _volume = 1; - private _duration: number | undefined = undefined; private _isStopped = false; - private _isPaused = false; + // private _isPaused = false; private _tracks: Audio[] = []; private _engine: Engine; private _wasPlayingOnHidden: boolean = false; @@ -117,7 +130,7 @@ export class Sound extends Class implements Audio, Loadable { } const arraybuffer = await this._resource.load(); const audiobuffer = await this.decodeAudio(arraybuffer.slice(0)); - this._duration = typeof audiobuffer === 'object' ? audiobuffer.duration : undefined; + this._duration = this._duration ?? audiobuffer?.duration ?? undefined; this.emit('processed', new NativeSoundProcessedEvent(this, audiobuffer)); return this.data = audiobuffer; } @@ -178,6 +191,10 @@ export class Sound extends Class implements Audio, Loadable { return this._tracks.some((t) => t.isPlaying()); } + public isPaused(): boolean { + return this._tracks.some(t => t.isPaused()); + } + /** * Play the sound, returns a promise that resolves when the sound is done playing * An optional volume argument can be passed in to play the sound. Max volume is 1.0 @@ -196,7 +213,7 @@ export class Sound extends Class implements Audio, Loadable { this.volume = volume || this.volume; - if (this._isPaused) { + if (this.isPaused()) { return this._resumePlayback(); } else { return this._startPlayback(); @@ -215,8 +232,6 @@ export class Sound extends Class implements Audio, Loadable { track.pause(); } - this._isPaused = true; - this.emit('pause', new NativeSoundEvent(this)); this.logger.debug('Paused all instances of sound', this.path); @@ -232,7 +247,6 @@ export class Sound extends Class implements Audio, Loadable { this.emit('stop', new NativeSoundEvent(this)); - this._isPaused = false; this._tracks.length = 0; this.logger.debug('Stopped all instances of sound', this.path); } @@ -248,6 +262,18 @@ export class Sound extends Class implements Audio, Loadable { }); } + public seek(position: number, trackId = 0) { + if (this._tracks.length === 0) { + this._getTrackInstance(this.data); + } + + this._tracks[trackId].seek(position); + } + + public getTotalPlaybackDuration() { + return this.data.duration; + } + /** * Return the current playback time of the playing track in seconds from the start. * @@ -272,15 +298,17 @@ export class Sound extends Class implements Audio, Loadable { } private async _resumePlayback(): Promise { - if (this._isPaused) { + if (this.isPaused) { const resumed: Promise[] = []; // ensure we resume *current* tracks (if paused) for (const track of this._tracks) { - resumed.push(track.play()); + resumed.push(track.play().then(() => { + this.emit('playbackend', new NativeSoundEvent(this, track as WebAudioInstance)); + this._tracks.splice(this.getTrackId(track), 1); + return true; + })); } - this._isPaused = false; - this.emit('resume', new NativeSoundEvent(this)); this.logger.debug('Resuming paused instances for sound', this.path, this._tracks); diff --git a/src/engine/Resources/Sound/WebAudioInstance.ts b/src/engine/Resources/Sound/WebAudioInstance.ts index 7b234c62..eef3f9e5 100644 --- a/src/engine/Resources/Sound/WebAudioInstance.ts +++ b/src/engine/Resources/Sound/WebAudioInstance.ts @@ -1,27 +1,113 @@ +import { StateMachine } from '../../Util/StateMachine'; import { Audio } from '../../Interfaces/Audio'; import { clamp } from '../../Math/util'; import { AudioContextFactory } from './AudioContext'; +interface SoundState { + startedAt: number; + pausedAt: number; +} + /** * Internal class representing a Web Audio AudioBufferSourceNode instance * @see https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API */ export class WebAudioInstance implements Audio { - private _volume = 1; - private _duration: number | undefined = undefined; - private _startedAt = 0; - private _pausedAt = 0; - - private _playingPromise: Promise; + private _instance: AudioBufferSourceNode; + private _audioContext: AudioContext = AudioContextFactory.create(); + private _volumeNode = this._audioContext.createGain(); private _playingResolve: (value: boolean) => void; + private _playingPromise = new Promise((resolve) => { + this._playingResolve = resolve; + }); + private _stateMachine = StateMachine.create({ + start: 'STOPPED', + states: { + PLAYING: { + onEnter: ({data}: {from: string, data: SoundState}) => { + + // Buffer nodes are single use + this._createNewBufferSource(); + this._handleEnd(); + this._instance.start(0, data.pausedAt * this._playbackRate, this.duration); + data.startedAt = (this._audioContext.currentTime - data.pausedAt); + data.pausedAt = 0; + }, + onState: () => this._playStarted(), + onExit: ({to}) => { + // If you've exited early only resolve if explicitly STOPPED + if (to === 'STOPPED') { + this._playingResolve(true); + } + // Whenever you're not playing... you stop! + this._instance.onended = null; // disconnect the wired on-end handler + this._instance.disconnect(); + this._instance.stop(0); + this._instance = null; + }, + transitions: ['STOPPED', 'PAUSED', 'SEEK'] + }, + SEEK: { + onEnter: ({ eventData: position, data }: {eventData?: number, data: SoundState}) => { + data.pausedAt = (position ?? 0) / this._playbackRate; + data.startedAt = 0; + }, + transitions: ['*'] + }, + STOPPED: { + onEnter: ({data}: {from: string, data: SoundState}) => { + data.pausedAt = 0; + data.startedAt = 0; + this._playingResolve(true); + }, + transitions: ['PLAYING', 'PAUSED', 'SEEK'] + }, + PAUSED: { + onEnter: ({data}: {data: SoundState}) => { + // Playback rate will be a scale factor of how fast/slow the audio is being played + // default is 1.0 + // we need to invert it to get the time scale + data.pausedAt = (this._audioContext.currentTime - data.startedAt); + }, + transitions: ['PLAYING', 'STOPPED', 'SEEK'] + } + } + }, { + startedAt: 0, + pausedAt: 0 + } as SoundState); + + private _createNewBufferSource() { + this._instance = this._audioContext.createBufferSource(); + this._instance.buffer = this._src; + this._instance.loop = this.loop; + this._instance.playbackRate.value = this._playbackRate; + this._instance.connect(this._volumeNode); + this._volumeNode.connect(this._audioContext.destination); + } + + private _handleEnd() { + if (!this.loop) { + this._instance.onended = () => { + this._playingResolve(true); + }; + } + } + private _volume = 1; private _loop = false; + // eslint-disable-next-line @typescript-eslint/no-empty-function + private _playStarted: () => any = () => {}; public set loop(value: boolean) { this._loop = value; if (this._instance) { this._instance.loop = value; - this._wireUpOnEnded(); + if (!this.loop) { + this._instance.onended = () => { + this._playingResolve(true); + }; + } } } public get loop(): boolean { @@ -33,7 +119,7 @@ export class WebAudioInstance implements Audio { this._volume = value; - if (this._isPlaying && this._volumeNode.gain.setTargetAtTime) { + if (this._stateMachine.in('PLAYING') && this._volumeNode.gain.setTargetAtTime) { // https://developer.mozilla.org/en-US/docs/Web/API/AudioParam/setTargetAtTime // After each .1 seconds timestep, the target value will ~63.2% closer to the target value. // This exponential ramp provides a more pleasant transition in gain @@ -46,181 +132,78 @@ export class WebAudioInstance implements Audio { return this._volume; } - public set duration(value: number | undefined) { - this._duration = value; - } - + private _duration: number | undefined; /** - * Duration of the sound, in seconds. + * Returns the set duration to play, otherwise returns the total duration if unset */ public get duration() { - return this._duration; - } - - private get _playbackRate(): number { - return this._instance ? 1 / (this._instance.playbackRate.value || 1.0) : null; + return this._duration ?? this.getTotalPlaybackDuration(); } - private _isPlaying = false; - private _isPaused = false; - private _instance: AudioBufferSourceNode; - private _audioContext: AudioContext = AudioContextFactory.create(); - private _volumeNode = this._audioContext.createGain(); - private _startTime: number; - /** - * Current playback offset (in seconds) + * Set the duration that this audio should play. + * + * Note: if you seek to a specific point the duration will start from that point, for example + * + * If you have a 10 second clip, seek to 5 seconds, then set the duration to 2, it will play the clip from 5-7 seconds. */ - private _currentOffset = 0; + public set duration(duration: number) { + this._duration = duration; + } constructor(private _src: AudioBuffer) { this._createNewBufferSource(); } public isPlaying() { - return this._isPlaying; + return this._stateMachine.in('PLAYING'); + } + + public isPaused() { + return this._stateMachine.in('PAUSED') || this._stateMachine.in('SEEK'); } // eslint-disable-next-line @typescript-eslint/no-empty-function public play(playStarted: () => any = () => {}) { - if (this._isPaused) { - this._resumePlayBack(); - playStarted(); - this._startedAt = this._audioContext.currentTime - this._pausedAt; - this._pausedAt = 0; - } - - if (!this._isPlaying) { - this._startPlayBack(); - playStarted(); - this._startedAt = this._audioContext.currentTime; - this._pausedAt = 0; - } + this._playStarted = playStarted; + this._stateMachine.go('PLAYING'); return this._playingPromise; } public pause() { - if (!this._isPlaying) { - return; - } - - this._isPaused = true; - this._isPlaying = false; - - this._instance.stop(0); - // Playback rate will be a scale factor of how fast/slow the audio is being played - // default is 1.0 - // we need to invert it to get the time scale - this._setPauseOffset(); - this._pausedAt = (this._audioContext.currentTime - this._startedAt) * this._instance.playbackRate.value; + this._stateMachine.go('PAUSED'); } public stop() { - if (!this._isPlaying) { - return; - } - - this._isPlaying = false; - this._isPaused = false; - - this._currentOffset = 0; - this._instance.stop(0); - - // handler will not be wired up if we were looping - if (!this._instance.onended) { - this._handleOnEnded(); - } + this._stateMachine.go('STOPPED'); } - private _startPlayBack() { - this._isPlaying = true; - this._isPaused = false; - this._playingPromise = new Promise((resolve) => { - this._playingResolve = resolve; - }); - - if (!this._instance) { - this._createNewBufferSource(); - } - - this._rememberStartTime(); - - this._volumeNode.connect(this._audioContext.destination); - this._instance.start(0, 0); - this._currentOffset = 0; - - this._wireUpOnEnded(); + public seek(position: number) { + this._stateMachine.go('PAUSED'); + this._stateMachine.go('SEEK', position); } - private _resumePlayBack() { - if (!this._isPaused) { - return; - } - - this._isPaused = false; - this._isPlaying = true; - - // a buffer source can only be started once - // so we need to dispose of the previous instance before - // "resuming" the next one - this._instance.onended = null; // dispose of any previous event handler - this._createNewBufferSource(); - - const duration = this._playbackRate * this._src.duration; - const restartTime = this._currentOffset % duration; - - this._rememberStartTime(restartTime * -1000); - this._instance.start(0, restartTime); - this._wireUpOnEnded(); + public getTotalPlaybackDuration() { + return this._src.duration; } - private _wireUpOnEnded() { - if (!this.loop) { - this._instance.onended = () => this._handleOnEnded(); + public getPlaybackPosition() { + const {pausedAt, startedAt} = this._stateMachine.data; + if (pausedAt) { + return pausedAt * this._playbackRate; } - } - - private _handleOnEnded() { - // pausing calls stop(0) which triggers onended event - // so we don't "resolve" yet (when we resume we'll try again) - if (!this._isPaused) { - this._isPlaying = false; - this._playingResolve(true); + if (startedAt) { + return (this._audioContext.currentTime - startedAt) * this._playbackRate; } + return 0; } - private _rememberStartTime(amend?: number) { - this._startTime = new Date().getTime() + (amend | 0); - } - - private _setPauseOffset() { - this._currentOffset = ((new Date().getTime() - this._startTime) * this._playbackRate) / 1000; // in seconds - } - + private _playbackRate = 1.0; public set playbackRate(playbackRate: number) { - this._instance.playbackRate.value = playbackRate; + this._instance.playbackRate.value = this._playbackRate = playbackRate; } public get playbackRate() { return this._instance.playbackRate.value; } - - public getPlaybackPosition() { - if (this._pausedAt) { - return this._pausedAt; - } - if (this._startedAt) { - return (this._audioContext.currentTime - this._startedAt) * (this._instance?.playbackRate.value ?? 1); - } - return 0; - } - - private _createNewBufferSource() { - this._instance = this._audioContext.createBufferSource(); - this._instance.buffer = this._src; - this._instance.loop = this.loop; - this._instance.playbackRate.setValueAtTime(1.0, 0); - - this._instance.connect(this._volumeNode); - } } diff --git a/src/engine/Util/StateMachine.ts b/src/engine/Util/StateMachine.ts new file mode 100644 index 00000000..b491c55c --- /dev/null +++ b/src/engine/Util/StateMachine.ts @@ -0,0 +1,112 @@ + +export interface State { + name?: string; + transitions: string[]; + onEnter?: (context: {from: string, eventData?: any, data: any}) => boolean | void; + onState?: () => any; + onExit?: (context: {to: string, data: any}) => boolean | void; + onUpdate?: (data: any, elapsedMs: number) => any; +} + +export interface StateMachineDescription { + start: string, + states: { [name: string]: State } +} + +export type PossibleStates = TMachine extends StateMachineDescription ? Extract : never; + +export interface StateMachineState { + data: any; + currentState: string; +} + +export class StateMachine { + public startState: State; + private _currentState: State; + public get currentState(): State { + return this._currentState; + } + public set currentState(state: State) { + this._currentState = state; + } + public states = new Map(); + public data: TData; + + static create( + machineDescription: TMachine, data?: TData): StateMachine, TData> { + const machine = new StateMachine, TData>(); + machine.data = data; + for (const stateName in machineDescription.states) { + machine.states.set(stateName as PossibleStates, { + name: stateName, + ...machineDescription.states[stateName] + }); + } + + // validate transitions are states + for (const state of machine.states.values()) { + for (const transitionState of state.transitions) { + if (transitionState === '*') { + continue; + } + if (!machine.states.has(transitionState)) { + throw Error( + `Invalid state machine, state [${state.name}] has a transition to another state that doesn't exist [${transitionState}]` + ); + } + } + } + machine.currentState = machine.startState = machine.states.get(machineDescription.start); + return machine; + } + + in(state: TPossibleStates): boolean { + return this.currentState.name === state; + } + + go(stateName: TPossibleStates, eventData?: any): boolean { + if (this.currentState.transitions.includes(stateName) || this.currentState.transitions.includes('*')) { + const potentialNewState = this.states.get(stateName); + if (this.currentState.onExit) { + const canExit = this.currentState?.onExit({to: potentialNewState.name, data: this.data}); + if (canExit === false) { + return false; + } + } + + if (potentialNewState?.onEnter) { + const canEnter = potentialNewState?.onEnter({from: this.currentState.name, eventData, data: this.data}); + if (canEnter === false) { + return false; + } + } + // console.log(`${this.currentState.name} => ${potentialNewState.name} (${eventData})`); + this.currentState = potentialNewState; + if (this.currentState?.onState) { + this.currentState.onState(); + } + return true; + } + return false; + } + + update(elapsedMs: number) { + if (this.currentState.onUpdate) { + this.currentState.onUpdate(this.data, elapsedMs); + } + } + + save(saveKey: string) { + localStorage.setItem(saveKey, JSON.stringify({ + currentState: this.currentState.name, + data: this.data + })); + } + + restore(saveKey: string) { + const state: StateMachineState = JSON.parse(localStorage.getItem(saveKey)); + this.currentState = this.states.get(state.currentState); + this.data = state.data; + } +} + diff --git a/src/engine/index.ts b/src/engine/index.ts index df9cf35d..9048d9b4 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -71,6 +71,7 @@ export * from './Util/Fps'; export * from './Util/Clock'; export * from './Util/WebAudio'; export * from './Util/Toaster'; +export * from './Util/StateMachine'; // ex.Deprecated // import * as deprecated from './Deprecated'; diff --git a/src/spec/SoundSpec.ts b/src/spec/SoundSpec.ts index 62a599ab..0fa37c21 100644 --- a/src/spec/SoundSpec.ts +++ b/src/spec/SoundSpec.ts @@ -28,9 +28,11 @@ describe('Sound resource', () => { }); it('should have duration', async () => { + sut = new ex.Sound('src/spec/images/SoundSpec/preview.ogg'); + sut.duration = 5.0; await sut.load(); expect(sut.duration).toBeDefined(); - expect(sut.duration).toBeGreaterThan(0); + expect(sut.duration).toBe(5); }); it('should fire playbackstart event', async () => { @@ -184,7 +186,6 @@ describe('Sound resource', () => { it('should stop all currently playing tracks', (done) => { sut.load().then(() => { - sut.play(); sut.once('playbackstart', () => { expect(sut.isPlaying()).toBe(true, 'track should be playing'); @@ -194,6 +195,8 @@ describe('Sound resource', () => { done(); }); + + sut.play(); }); }); @@ -311,6 +314,38 @@ describe('Sound resource', () => { }); }); + it('can seek to a position in the sound', async () => { + sut = new ex.Sound('src/spec/images/SoundSpec/preview.ogg'); + await sut.load(); + expect(sut.getPlaybackPosition()).toBe(0); + sut.seek(6.5); + expect(sut.getPlaybackPosition()).toBe(6.5); + }); + + it('can get the total duration of the sound', async () => { + sut = new ex.Sound('src/spec/images/SoundSpec/preview.ogg'); + await sut.load(); + expect(sut.getTotalPlaybackDuration()).toBeCloseTo(13.01, 1); + }); + + + it('can set/get the playback rate', async () => { + sut = new ex.Sound('src/spec/images/SoundSpec/preview.ogg'); + expect(sut.playbackRate).toBe(1.0); + sut.playbackRate = 2.5; + await sut.load(); + expect(sut.playbackRate).toBe(2.5); + }); + + it('can set the playback rate and seek to the right position', async () => { + sut = new ex.Sound('src/spec/images/SoundSpec/preview.ogg'); + expect(sut.playbackRate).toBe(1.0); + sut.playbackRate = 2.5; + await sut.load(); + sut.seek(6.5); + expect(sut.getPlaybackPosition()).toBe(6.5); + }); + describe('wire engine', () => { let engine: ex.Engine; @@ -365,18 +400,20 @@ describe('Sound resource', () => { sut.load().then(() => { engine.pauseAudioWhenHidden = true; sut.wireEngine(engine); - sut.play(); + sut.once('playbackstart', () => { - expect(sut.isPlaying()).toBe(true, 'should be playing'); + expect(sut.isPlaying()).withContext('should be playing').toBe(true); setTimeout(() => { engine.emit('hidden', new ex.HiddenEvent(engine)); }, 100); }); + sut.play(); + engine.once('hidden', () => { - expect(sut.isPlaying()).toBe(false, 'should pause when hidden'); + expect(sut.isPlaying()).withContext('should pause when hidden').toBe(false); done(); }); }); diff --git a/src/spec/StateMachineSpec.ts b/src/spec/StateMachineSpec.ts new file mode 100644 index 00000000..2b681655 --- /dev/null +++ b/src/spec/StateMachineSpec.ts @@ -0,0 +1,207 @@ +import * as ex from '@excalibur'; + +describe('A StateMachine', () => { + it('exists', () => { + expect(ex.StateMachine).toBeDefined(); + }); + + it('can construct a state machine', () => { + + const machine = ex.StateMachine.create({ + start: 'STOPPED', + states: { + PLAYING: { + transitions: ['STOPPED', 'PAUSED'] + }, + STOPPED: { + transitions: ['PLAYING'] + }, + PAUSED: { + transitions: ['PLAYING', 'STOPPED'] + } + } + }); + + expect(machine.currentState.name).toBe('STOPPED'); + expect(machine.startState.name).toBe('STOPPED'); + expect(Array.from(machine.states.keys())).toEqual(['PLAYING', 'STOPPED', 'PAUSED']); + expect(machine).toBeDefined(); + }); + + it('will throw on an invalid state machine', () => { + expect(() => { + + const machine = ex.StateMachine.create({ + start: 'STOPPED', + states: { + PLAYING: { + transitions: ['STOPPED', 'DOESNTEXIST'] + }, + STOPPED: { + transitions: ['PLAYING'] + }, + PAUSED: { + transitions: ['PLAYING', 'STOPPED'] + } + } + }); + }).toThrow(new Error(`Invalid state machine, state [PLAYING] has a transition to another state that doesn't exist [DOESNTEXIST]`)); + }); + + it('can transition to valid state', () => { + const machine = ex.StateMachine.create({ + start: 'STOPPED', + states: { + PLAYING: { + transitions: ['STOPPED', 'PAUSED'] + }, + STOPPED: { + transitions: ['PLAYING', 'SEEK'] + }, + SEEK: { + transitions: ['*'] + }, + PAUSED: { + transitions: ['PLAYING', 'STOPPED'] + } + } + }); + + expect(machine.go('PLAYING')).toBe(true); + expect(machine.go('PAUSED')).toBe(true); + expect(machine.go('STOPPED')).toBe(true); + + // No implicit self transition + expect(machine.go('STOPPED')).toBe(false); + expect(machine.go('SEEK')).toBe(true); + expect(machine.go('PLAYING')).toBe(true); + }); + + it('can prevent transition onEnter', () => { + + const enterSpy = jasmine.createSpy('enter').and.returnValue(false); + const exitSpy = jasmine.createSpy('exit').and.returnValue(false); + const machine = ex.StateMachine.create({ + start: 'PING', + states: { + PING: { + transitions: ['PONG'] + }, + PONG: { + onEnter: enterSpy, + onExit: exitSpy, + transitions: ['PING'] + } + } + }); + + expect(machine.go('PONG')).toBe(false); + enterSpy.and.returnValue(true); + expect(machine.go('PONG')).toBe(true); + expect(machine.go('PING')).toBe(false); + exitSpy.and.returnValue(true); + expect(machine.go('PING')).toBe(true); + }); + + it('can pass data to states', () => { + const enterSpy = jasmine.createSpy('enter').and.returnValue(true); + const exitSpy = jasmine.createSpy('exit').and.returnValue(true); + const machine = ex.StateMachine.create({ + start: 'PING', + states: { + PING: { + transitions: ['PONG'] + }, + PONG: { + onEnter: enterSpy, + onExit: exitSpy, + transitions: ['PING'] + } + } + }, { + data: 42 + }); + + machine.go('PONG'); + expect(enterSpy).toHaveBeenCalledWith({from: 'PING', eventData: undefined, data: { data: 42 }}); + machine.go('PING'); + expect(exitSpy).toHaveBeenCalledWith({to: 'PING', data: { data: 42 }}); + }); + + it('can update the current state', () => { + const updateSpy = jasmine.createSpy('update').and.returnValue(true); + const machine = ex.StateMachine.create({ + start: 'PING', + states: { + PING: { + transitions: ['PONG'] + }, + PONG: { + onUpdate: updateSpy, + transitions: ['PING'] + } + } + }, { + data: 42 + }); + + machine.go('PONG'); + machine.update(100); + expect(updateSpy).toHaveBeenCalledWith({ data: 42 }, 100); + }); + + it('can save a state machine to local storage and restore it with the correct state and data', () => { + const machine = ex.StateMachine.create({ + start: 'PING', + states: { + PING: { + onEnter: ({data}) => { + data.data++; + }, + transitions: ['PONG'] + }, + PONG: { + onEnter: ({data}) => { + data.data++; + }, + transitions: ['PING'] + } + } + }, { + data: 42 + }); + machine.go('PONG'); + expect(machine.data.data).toBe(43); + machine.go('PING'); + expect(machine.data.data).toBe(44); + machine.go('PONG'); + expect(machine.data.data).toBe(45); + + machine.save('my-machine-state'); + + const newMachine = ex.StateMachine.create({ + start: 'PING', + states: { + PING: { + onEnter: ({data}) => { + data.data++; + }, + transitions: ['PONG'] + }, + PONG: { + onEnter: ({data}) => { + data.data++; + }, + transitions: ['PING'] + } + } + }, { + data: 42 + }); + + newMachine.restore('my-machine-state'); + expect(newMachine.currentState.name).toBe('PONG'); + expect(newMachine.data.data).toBe(45); + }); +}); + -- 2.51.2