From 31d049c48e178fbf71cf78c0ac8adafcc63a9bf4 Mon Sep 17 00:00:00 2001 From: Jan Francis Berdan Date: Wed, 14 Jan 2026 14:47:47 +0800 Subject: [PATCH 01/20] feat: Implemented Scheduler System for the gameplay pause mechanic --- src/common/stone-config/stone-config.spec.ts | 38 ++++----- src/common/stone-config/stone-config.ts | 11 +-- src/components/audio-player.ts | 16 +++- src/components/prompt-text/prompt-text.scss | 11 ++- src/components/prompt-text/prompt-text.ts | 25 +++++- .../riveComponent/rive-component.ts | 8 ++ .../stone-handler/stone-handler.spec.ts | 6 +- src/components/stone-handler/stone-handler.ts | 8 +- src/components/timer-ticking.ts | 3 +- .../feedbackAudioHandler.ts | 7 +- .../puzzleHandler/puzzleHandler.ts | 4 +- .../gameplay-scene/gameplay-flow-manager.ts | 7 +- src/scenes/gameplay-scene/gameplay-scene.ts | 31 ++++++-- .../gameplay-scene/monster-controller.ts | 11 ++- src/services/scheduler.ts | 79 +++++++++++++++++++ src/tutorials/index.ts | 7 +- 16 files changed, 217 insertions(+), 55 deletions(-) create mode 100644 src/services/scheduler.ts diff --git a/src/common/stone-config/stone-config.spec.ts b/src/common/stone-config/stone-config.spec.ts index c40eac2fb..0f36ec7d5 100644 --- a/src/common/stone-config/stone-config.spec.ts +++ b/src/common/stone-config/stone-config.spec.ts @@ -76,19 +76,19 @@ describe('StoneConfig', () => { stoneConfig.initialize(); stoneConfig['animationStartTime'] = 0; now = 0; - stoneConfig.draw(); + stoneConfig.draw(0); expect(stoneConfig.getX()).toBe(0); // 25% through animation now = 250; - stoneConfig.draw(); + stoneConfig.draw(0); stoneConfig.frame = 25; // Explicitly set frame to match elapsed time const expected25X = targetX * (1 - Math.cos(Math.PI * 0.25)) / 2; expect(stoneConfig.getX()).toBeCloseTo(expected25X, 0); // Complete animation now = 1000; - stoneConfig.draw(); + stoneConfig.draw(0); stoneConfig.frame = 100; // Explicitly set frame for completion expect(stoneConfig.getX()).toBe(targetX); }); @@ -109,19 +109,19 @@ describe('StoneConfig', () => { stoneConfig.initialize(); stoneConfig['animationStartTime'] = 0; now = 0; - stoneConfig.draw(); + stoneConfig.draw(0); expect(stoneConfig.getY()).toBe(0); // 25% through animation now = 250; - stoneConfig.draw(); + stoneConfig.draw(0); stoneConfig.frame = 25; // Explicitly set frame to match elapsed time const expected25Y = targetY * (1 - Math.cos(Math.PI * 0.25)) / 2; expect(stoneConfig.getY()).toBeCloseTo(expected25Y, 0); // Complete animation now = 1000; - stoneConfig.draw(); + stoneConfig.draw(0); stoneConfig.frame = 100; // Explicitly set frame for completion expect(stoneConfig.getY()).toBe(targetY); }); @@ -137,7 +137,7 @@ describe('StoneConfig', () => { stoneConfig.initialize(); stoneConfig['animationStartTime'] = 0; now = 0; - stoneConfig.draw(); + stoneConfig.draw(0); expect(stoneConfig.getX()).toBe(0); expect(stoneConfig.getY()).toBe(0); @@ -145,7 +145,7 @@ describe('StoneConfig', () => { const checkPoints = [0.25, 0.5, 0.75]; checkPoints.forEach(progress => { now = progress * 1000; - stoneConfig.draw(); + stoneConfig.draw(0); stoneConfig.frame = progress * 100; // Set frame to match progress const expectedX = targetX * (1 - Math.cos(Math.PI * progress)) / 2; @@ -168,7 +168,7 @@ describe('StoneConfig', () => { const drawImageSpy = jest.spyOn(mockContext, 'drawImage'); const fillTextSpy = jest.spyOn(mockContext, 'fillText'); - stoneConfig.draw(); + stoneConfig.draw(0); // Verify essential drawing operations expect(drawImageSpy).toHaveBeenCalledTimes(1); @@ -196,32 +196,32 @@ describe('StoneConfig', () => { now = 0; // First draw to start animation - stoneConfig.draw(); + stoneConfig.draw(0); stoneConfig.frame = 0; expect(stoneConfig.frame).toBe(0); // 25% through animation (250ms / 1000ms * 100 = 25) now = 250; - stoneConfig.draw(); + stoneConfig.draw(0); // Manually set frame since we're mocking time stoneConfig.frame = Math.floor((now / 1000) * 100); expect(stoneConfig.frame).toBe(25); // 50% through animation now = 500; - stoneConfig.draw(); + stoneConfig.draw(0); stoneConfig.frame = Math.floor((now / 1000) * 100); expect(stoneConfig.frame).toBe(50); // Complete animation now = 1000; - stoneConfig.draw(); + stoneConfig.draw(0); stoneConfig.frame = 100; expect(stoneConfig.frame).toBe(100); // Past completion now = 1500; - stoneConfig.draw(); + stoneConfig.draw(0); expect(stoneConfig.frame).toBe(100); // Should stay at 100 }); @@ -231,25 +231,25 @@ describe('StoneConfig', () => { now = 0; // First draw to start animation - stoneConfig.draw(); + stoneConfig.draw(0); stoneConfig.frame = 0; expect(stoneConfig.frame).toBe(0); // Near completion now = 990; - stoneConfig.draw(); + stoneConfig.draw(0); stoneConfig.frame = Math.floor((now / 1000) * 100); expect(stoneConfig.frame).toBe(99); // Just past completion now = 1010; - stoneConfig.draw(); + stoneConfig.draw(0); stoneConfig.frame = 100; expect(stoneConfig.frame).toBe(100); // Well past completion now = 2000; - stoneConfig.draw(); + stoneConfig.draw(0); expect(stoneConfig.frame).toBe(100); }); }); @@ -264,7 +264,7 @@ describe('StoneConfig', () => { const drawImageSpy = jest.spyOn(mockContext, 'drawImage'); stoneConfig.dispose(); - stoneConfig.draw(); + stoneConfig.draw(0); expect(drawImageSpy).not.toHaveBeenCalled(); }); diff --git a/src/common/stone-config/stone-config.ts b/src/common/stone-config/stone-config.ts index e9514997a..ef833b8e7 100644 --- a/src/common/stone-config/stone-config.ts +++ b/src/common/stone-config/stone-config.ts @@ -22,7 +22,6 @@ export class StoneConfig { public frame: number = 0; public isDisposed: boolean = false; // Performance optimization: Use time-based animation for smoother movement - private animationStartTime: number = 0; private animationDuration: number = 1500; // 1.5 second animation public scale = gameSettingsService.getDevicePixelRatioValue(); constructor(context, canvasWidth, canvasHeight, stoneLetter, xPos, yPos, img) { @@ -43,7 +42,6 @@ export class StoneConfig { public initialize() { this.frame = 0; this.isDisposed = false; - this.animationStartTime = 0; } public get isAnimating(): boolean { @@ -110,16 +108,13 @@ export class StoneConfig { * - Uses time-based animation for smooth movement * - Only applies effects when necessary */ - draw(shouldResize: boolean = false) { + draw(deltaTime: number, shouldResize: boolean = false) { if (this.isDisposed || !this.img || !this.context) return; // Update animation based on actual time elapsed if (this.frame < 100) { - if (this.animationStartTime === 0) { - this.animationStartTime = performance.now(); - } - const elapsed = performance.now() - this.animationStartTime; - this.frame = Math.min(100, (elapsed / this.animationDuration) * 100); + this.frame += (deltaTime / this.animationDuration) * 100; + this.frame = Math.min(100, this.frame); } //shouldResize is used when stone letters are grouped together when playing word puzzle game types. const x = this.getX() - (shouldResize ? this.imageCenterOffsetX * 1.25 : this.imageCenterOffsetX); diff --git a/src/components/audio-player.ts b/src/components/audio-player.ts index 0ec2b9caf..c1f2bee8f 100644 --- a/src/components/audio-player.ts +++ b/src/components/audio-player.ts @@ -1,5 +1,7 @@ import { Window } from "@common"; import { AUDIO_PATH_BTN_CLICK } from "@constants"; +import scheduler from "../services/scheduler"; + export class AudioPlayer { public static instance: AudioPlayer; @@ -211,7 +213,7 @@ export class AudioPlayer { } // Schedule the next audio play after current one ends - this.playAudioTimeoutId = setTimeout(() => { + this.playAudioTimeoutId = scheduler.setTimeout(() => { //Call playPromptAudio with a callback for onended method to call. this.playPromptAudio(() => { this.isPromptAudioPlaying = false; @@ -246,6 +248,18 @@ export class AudioPlayer { this.audioSourcs = []; }; + pauseAllAudios = () => { + if( this.audioContext && this.audioContext.state === "running") { + this.audioContext.suspend(); + } + } + + resumeAllAudios = () => { + if( this.audioContext && this.audioContext.state === "suspended") { + this.audioContext.resume(); + } + } + private playFetch = (index: number, loop: boolean) => { if (index >= this.audioQueue.length) { this.stopFeedbackAudio(); diff --git a/src/components/prompt-text/prompt-text.scss b/src/components/prompt-text/prompt-text.scss index 06d671bbd..97be60623 100644 --- a/src/components/prompt-text/prompt-text.scss +++ b/src/components/prompt-text/prompt-text.scss @@ -16,6 +16,15 @@ justify-content: center; align-items: center; } + + // When the container has the 'paused' class, pause all child animations + &.paused { + .floating-pulse, + .pulsing, + .text-red-pulse-letter { + animation-play-state: paused !important; + } + } } .prompt-center-responsive { @@ -296,4 +305,4 @@ top: 18%; } -} \ No newline at end of file +} diff --git a/src/components/prompt-text/prompt-text.ts b/src/components/prompt-text/prompt-text.ts index 0c8a4f5f0..3e003d92d 100644 --- a/src/components/prompt-text/prompt-text.ts +++ b/src/components/prompt-text/prompt-text.ts @@ -5,6 +5,8 @@ import { PROMPT_TEXT_BG, AUDIO_PLAY_BUTTON } from "@constants"; import { BaseHTML, BaseHtmlOptions } from "../baseHTML/base-html"; import './prompt-text.scss'; import gameStateService from '@gameStateService'; +import scheduler from "../../services/scheduler"; + // Default selectors for the prompt text component export const DEFAULT_SELECTORS = { @@ -93,6 +95,7 @@ export class PromptText extends BaseHTML { private onClickCallback?: () => void; private unsubscribeSubmittedLettersEvent: () => void; private unsubscribeHasGameStartedEvent: () => void; + private unsubscribeGamePauseEvent: () => void; /** * Initializes a new instance of the PromptText class. @@ -172,6 +175,11 @@ export class PromptText extends BaseHTML { } } ) + + this.unsubscribeGamePauseEvent = gameStateService.subscribe( + gameStateService.EVENTS.GAME_PAUSE_STATUS_EVENT, + (isPaused: boolean) => this.handleGamePause(isPaused) + ); } private setPromptInitialAudioDelayValues(isTutorialOn: boolean = false) { @@ -196,7 +204,7 @@ export class PromptText extends BaseHTML { } private runAfterInitialAudioDelay(delayMs: number, callback: () => void) { - setTimeout(callback, delayMs); + scheduler.setTimeout(callback, delayMs); } private schedulePromptAndPulseUpdate() { @@ -502,7 +510,7 @@ export class PromptText extends BaseHTML { } private handleAutoPromptPlay() { - setTimeout(() => { + scheduler.setTimeout(() => { if (!this.isAutoPromptPlaying) { this.audioPlayer.handlePlayPromptAudioClickEvent(); } @@ -532,6 +540,18 @@ export class PromptText extends BaseHTML { } } + private handleGamePause(isPaused: boolean) { + if (!this.promptContainer) { + return; + } + + if (isPaused) { + this.promptContainer.classList.add('paused'); + } else { + this.promptContainer.classList.remove('paused'); + } + } + /** * Handles load puzzle events. * @param event The event. @@ -560,6 +580,7 @@ export class PromptText extends BaseHTML { //unsubscribe to gameStateService event. this.unsubscribeSubmittedLettersEvent(); this.unsubscribeHasGameStartedEvent(); + this.unsubscribeGamePauseEvent(); // Use BaseHTML's destroy method to remove the element super.destroy(); } diff --git a/src/components/riveComponent/rive-component.ts b/src/components/riveComponent/rive-component.ts index d77c465e3..81031ea2c 100644 --- a/src/components/riveComponent/rive-component.ts +++ b/src/components/riveComponent/rive-component.ts @@ -114,6 +114,14 @@ export class RiveComponent extends PubSub { this.riveInstance?.play(animationName); } + public pause() { + this.riveInstance?.pause(); + } + + public resume() { + this.riveInstance?.play(); + } + public stop() { this.riveInstance?.stop(); } diff --git a/src/components/stone-handler/stone-handler.spec.ts b/src/components/stone-handler/stone-handler.spec.ts index 42c4acd4f..a6d90f2c9 100644 --- a/src/components/stone-handler/stone-handler.spec.ts +++ b/src/components/stone-handler/stone-handler.spec.ts @@ -147,7 +147,7 @@ describe('StoneHandler - Latest Optimizations', () => { ]; stoneHandler.foilStones = mockStones as any[]; - stoneHandler.draw(); + stoneHandler.draw(0); //If the last stone is still below 100 frame, it means the stones hasn't fully loaded yet. expect(stoneHandler.stonesHasLoaded).toEqual(false); }); @@ -159,11 +159,11 @@ describe('StoneHandler - Latest Optimizations', () => { ]; stoneHandler.foilStones = mockStones as any[]; - stoneHandler.draw(); + stoneHandler.draw(0); // Update second stone to complete animation mockStones[1].frame = 100; - stoneHandler.draw(); + stoneHandler.draw(0); }); diff --git a/src/components/stone-handler/stone-handler.ts b/src/components/stone-handler/stone-handler.ts index 04b907382..cb39998bb 100644 --- a/src/components/stone-handler/stone-handler.ts +++ b/src/components/stone-handler/stone-handler.ts @@ -159,11 +159,11 @@ export default class StoneHandler extends EventManager { * Performance optimized draw loop * Only processes active stones and updates timer efficiently */ - draw() { + draw(deltaTime: number) { if (this.foilStones.length === 0) return; for (const stone of this.foilStones) { - stone.draw(); + stone.draw(deltaTime); } !this.stonesHasLoaded && this.areStonesReadyForPlay(); @@ -171,11 +171,13 @@ export default class StoneHandler extends EventManager { drawWordPuzzleLetters( shouldHideStoneChecker: (index: number) => boolean, - groupedLetters: {} | { [key: number]: string } + groupedLetters: {} | { [key: number]: string }, + deltaTime: number ): void { for (let i = 0; i < this.foilStones.length; i++) { if (shouldHideStoneChecker(i)) { this.foilStones[i].draw( + deltaTime, Object.keys(groupedLetters).length > 1 && groupedLetters[i] !== undefined ); } diff --git a/src/components/timer-ticking.ts b/src/components/timer-ticking.ts index 7d082bbfb..38c076a18 100644 --- a/src/components/timer-ticking.ts +++ b/src/components/timer-ticking.ts @@ -4,6 +4,7 @@ import { AudioPlayer } from "@components"; import { TIMER_EMPTY, ROTATING_CLOCK, AUDIO_TIMEOUT } from "@constants"; import './timerHtml/timerHtml.scss'; import TimerHTMLComponent from './timerHtml/timerHtml'; +import scheduler from "../services/scheduler"; export default class TimerTicking extends EventManager { @@ -53,7 +54,7 @@ export default class TimerTicking extends EventManager { this.timerHtmlComponent = new TimerHTMLComponent('timer-ticking'); // Reference the container element for the "full timer" image // Verify and cache the DOM element after rendering - setTimeout(() => { + scheduler.setTimeout(() => { this.timerFullContainer = document.getElementById("timer-full-container"); if (this.timerFullContainer) this.timerFullContainer.style.width = "100%"; }, 0); diff --git a/src/gamepuzzles/feedbackAudioHandler/feedbackAudioHandler.ts b/src/gamepuzzles/feedbackAudioHandler/feedbackAudioHandler.ts index f795bdf95..6f0f34782 100644 --- a/src/gamepuzzles/feedbackAudioHandler/feedbackAudioHandler.ts +++ b/src/gamepuzzles/feedbackAudioHandler/feedbackAudioHandler.ts @@ -7,6 +7,7 @@ import { import { Utils } from '@common'; import gameStateService from '@gameStateService'; import { RiveMonsterComponent } from '@components/riveMonster/rive-monster-component'; +import scheduler from "../../services/scheduler"; /** * Feedback type enum for different feedback scenarios @@ -72,7 +73,7 @@ export default class FeedbackAudioHandler { */ private playIncorrectFeedbackSound(): void { - setTimeout(() => { + scheduler.setTimeout(() => { this.audioEndCallback(); }, 1700); // 1700ms is tailored to handleStoneDropEnd 1000 delay of isSpit animation } @@ -94,11 +95,11 @@ export default class FeedbackAudioHandler { Utils.getConvertedDevProdURL(this.feedbackAudios[feedBackIndex]) ) ]); - setTimeout(() => { + scheduler.setTimeout(() => { this.audioEndCallback(); // Callback after audios finish playing }, 4000); } catch (error) { - setTimeout(() => { + scheduler.setTimeout(() => { this.audioEndCallback(); // Ensure callback is called even if audio fails }, 4000); console.warn('Audio playback failed:', error); diff --git a/src/gamepuzzles/puzzleHandler/puzzleHandler.ts b/src/gamepuzzles/puzzleHandler/puzzleHandler.ts index 5843ab76b..b6685c723 100644 --- a/src/gamepuzzles/puzzleHandler/puzzleHandler.ts +++ b/src/gamepuzzles/puzzleHandler/puzzleHandler.ts @@ -3,6 +3,8 @@ import WordPuzzleLogic from '../wordPuzzleLogic/wordPuzzleLogic'; import { FeedbackTextEffects } from '@components/feedback-text'; import { FeedbackAudioHandler, FeedbackType } from '@gamepuzzles'; import gameStateService from '@gameStateService'; +import scheduler from "../../services/scheduler"; + /** * Context object for creating a puzzle/handling a letter drop. @@ -273,7 +275,7 @@ export default class PuzzleHandler { // Hide feedback text after audio finishes const totalAudioDuration = 4500; // Approximate duration of feedback audio - setTimeout(() => { + scheduler.setTimeout(() => { this.feedbackTextEffects.hideText(); }, totalAudioDuration); } diff --git a/src/scenes/gameplay-scene/gameplay-flow-manager.ts b/src/scenes/gameplay-scene/gameplay-flow-manager.ts index 9b8e6907d..1b8b893d1 100644 --- a/src/scenes/gameplay-scene/gameplay-flow-manager.ts +++ b/src/scenes/gameplay-scene/gameplay-flow-manager.ts @@ -22,6 +22,7 @@ import PuzzleHandler from "@gamepuzzles/puzzleHandler/puzzleHandler"; import { StoneHandler, AudioPlayer } from "@components"; import { MiniGameHandler } from '@miniGames/miniGameHandler'; import TutorialHandler from '@tutorials'; +import scheduler from "../../services/scheduler"; export class GameplayFlowManager { @@ -117,7 +118,7 @@ export class GameplayFlowManager { } else { // For incorrect answers only; Start loading the next puzzle with 2 seconds delay to let the audios play. const delay = this.isCorrect || isTimeOver ? 0 : 2000; - setTimeout(() => { + scheduler.setTimeout(() => { this.loadPuzzle(isTimeOver, loadPuzzleDelay); }, delay); } @@ -221,7 +222,7 @@ export class GameplayFlowManager { // Update the stars level indicator. this.uiManager.updateStars(this.currentPuzzleIndex, this.isCorrect); - setTimeout(() => { + scheduler.setTimeout(() => { this.logLevelEndFirebaseEvent(); const starsCount = GameScore.calculateStarCount(this.score); const levelEndData = { @@ -257,7 +258,7 @@ export class GameplayFlowManager { }, }); - setTimeout(() => { + scheduler.setTimeout(() => { if (!this.isDisposing) { this.initNewPuzzle(loadPuzzleEvent); this.uiManager.startTimer(); // Start the timer for the new puzzle diff --git a/src/scenes/gameplay-scene/gameplay-scene.ts b/src/scenes/gameplay-scene/gameplay-scene.ts index 175509ef5..a380d0a66 100644 --- a/src/scenes/gameplay-scene/gameplay-scene.ts +++ b/src/scenes/gameplay-scene/gameplay-scene.ts @@ -37,6 +37,7 @@ import { GameplayInputManager } from './gameplay-input-manager'; import { MonsterController } from './monster-controller'; import { GameplayUIManager } from "./gameplay-ui-manager"; import { GameplayFlowManager } from "./gameplay-flow-manager"; +import scheduler from "../../services/scheduler"; export class GameplayScene { // #region Properties @@ -95,6 +96,8 @@ export class GameplayScene { isFeedBackTriggered: boolean; // #endregion + private isPaused: boolean; + // #region Constructor constructor() { this.boundHandleVisibilityChange = this.handleVisibilityChange.bind(this); @@ -282,6 +285,13 @@ export class GameplayScene { // #region Game Loop draw(deltaTime: number) { const timeRef = { value: this.time }; + if (!this.isPaused) { + scheduler.update(deltaTime); + } + else + { + deltaTime = 0; + } this.tutorial?.handleTutorialAndGameStart({ deltaTime, isGameStarted: this.isGameStarted, @@ -291,6 +301,9 @@ export class GameplayScene { }); this.time = timeRef.value; + + + // Main game logic only starts after isGameStarted = true if (this.isGameStarted) { @@ -301,7 +314,7 @@ export class GameplayScene { this.uiManager.update(deltaTime, this.isGameStarted, this.isPauseButtonClicked, canUpdateTimer); - this.handleStoneLetterDrawing(); + this.handleStoneLetterDrawing(deltaTime); // Handle Timer Start SFX if (canUpdateTimer && !this.timerStartSFXPlayed && deltaTime > 1 && deltaTime <= 100) { @@ -320,14 +333,19 @@ export class GameplayScene { } public resumeGame(): void { + this.isPaused = false; + this.audioPlayer.resumeAllAudios(); // Resume the clock rotation when game is resumed this.uiManager.applyTimerRotation(this.uiManager.timerTicking?.startMyTimer && !this.uiManager.timerTicking?.isStoneDropped); + this.monsterController.resume(); } public pauseGamePlay(): void { - this.audioPlayer.stopAllAudios(); + this.isPaused = true; + this.audioPlayer.pauseAllAudios(); // Stop the clock rotation when game is paused this.uiManager.applyTimerRotation(false); + this.monsterController.pause(); } // #endregion @@ -419,7 +437,7 @@ export class GameplayScene { } public handleVisibilityChange(): void { - this.audioPlayer.stopAllAudios(); + // this.audioPlayer.stopAllAudios(); gameStateService.publish(gameStateService.EVENTS.GAME_PAUSE_STATUS_EVENT, true); this.pauseGamePlay(); } @@ -454,16 +472,17 @@ export class GameplayScene { this.backgroundGenerator.generateBackground(this.monsterController.currentPhase as any); } - private handleStoneLetterDrawing() { + private handleStoneLetterDrawing(deltaTime: number) { if (this.puzzleHandler.checkIsWordPuzzle()) { this.stoneHandler.drawWordPuzzleLetters( (foilStoneIndex) => { return this.puzzleHandler.validateShouldHideLetter(foilStoneIndex); }, - this.puzzleHandler.getWordPuzzleGroupedObj() + this.puzzleHandler.getWordPuzzleGroupedObj(), + deltaTime ); } else { - this.stoneHandler.draw(); + this.stoneHandler.draw(deltaTime); } } diff --git a/src/scenes/gameplay-scene/monster-controller.ts b/src/scenes/gameplay-scene/monster-controller.ts index d01e9e258..b8d74928e 100644 --- a/src/scenes/gameplay-scene/monster-controller.ts +++ b/src/scenes/gameplay-scene/monster-controller.ts @@ -3,6 +3,7 @@ import { RiveMonsterComponent } from '@components/riveMonster/rive-monster-compo import gameSettingsService from '@gameSettingsService'; import gameStateService from '@gameStateService'; import { GameplayInputManager } from "./gameplay-input-manager"; +import scheduler from "../../services/scheduler"; export class MonsterController { // #region Properties @@ -84,11 +85,19 @@ export class MonsterController { public triggerMonsterAnimation(animationName: string): void { const delay = this.animationDelays[this.monsterPhaseNumber]?.[animationName] ?? 0; if (delay > 0) { - setTimeout(() => this.monster?.triggerInput(animationName), delay); + scheduler.setTimeout(() => this.monster?.triggerInput(animationName), delay); } else { this.monster?.triggerInput(animationName); } } + + public pause(): void { + this.monster?.pause(); + } + + public resume(): void { + this.monster?.resume(); + } // #endregion // #region Interaction & Hitbox diff --git a/src/services/scheduler.ts b/src/services/scheduler.ts new file mode 100644 index 000000000..84454dbe7 --- /dev/null +++ b/src/services/scheduler.ts @@ -0,0 +1,79 @@ +import { Opaque } from 'type-fest'; + +type TimerId = Opaque; + +type Timer = { + id: TimerId; + callback: () => void; + delay: number; + remaining: number; + loop: boolean; +}; + +let nextTimerId = 0; + +class Scheduler { + private timers: Map = new Map(); + + setTimeout(callback: () => void, delay: number): TimerId { + const id = nextTimerId++ as TimerId; + this.timers.set(id, { + id, + callback, + delay, + remaining: delay, + loop: false, + }); + return id; + } + + clearTimeout(id: TimerId): void { + if (id !== undefined && id !== null) { + this.timers.delete(id); + } + } + + setInterval(callback: () => void, delay: number): TimerId { + const id = nextTimerId++ as TimerId; + this.timers.set(id, { + id, + callback, + delay, + remaining: delay, + loop: true, + }); + return id; + } + + clearInterval(id: TimerId): void { + this.clearTimeout(id); + } + + update(delta: number): void { + for (const timer of this.timers.values()) { + timer.remaining -= delta; + if (timer.remaining <= 0) { + try { + timer.callback(); + } catch (e) { + console.error("Error in scheduled callback:", e); + } + + if (timer.loop) { + timer.remaining += timer.delay; + } else { + this.timers.delete(timer.id); + } + } + } + } + + destroy(): void { + this.timers.clear(); + } +} + +// Making it a singleton +const scheduler = new Scheduler(); +export default scheduler; +export { TimerId }; \ No newline at end of file diff --git a/src/tutorials/index.ts b/src/tutorials/index.ts index c4f8b9aba..ed15e0647 100644 --- a/src/tutorials/index.ts +++ b/src/tutorials/index.ts @@ -4,6 +4,7 @@ import WordPuzzleTutorial from './WordPuzzleTutorial/WordPuzzleTutorial'; import AudioPuzzleTutorial from './AudioPuzzleTutorial/AudioPuzzleTutorial'; import gameStateService from '@gameStateService'; import { getGameTypeName, isGameTypeAudio } from '@common'; +import scheduler, { TimerId } from '../services/scheduler'; type TutorialInitParams = { context: CanvasRenderingContext2D; @@ -13,7 +14,7 @@ type TutorialInitParams = { shouldHaveTutorial?: boolean; }; export default class TutorialHandler { - private quickStartTutorialTimerId: ReturnType | null = null; + private quickStartTutorialTimerId: TimerId | null = null; public quickStartTutorialReady: boolean = false; public shouldShowQuickStartTutorial: boolean = false; // Set externally as needed private width: number; @@ -323,13 +324,13 @@ export default class TutorialHandler { public resetQuickStartTutorialDelay() { // Always clear any previous timer to avoid overlap if (this.quickStartTutorialTimerId !== null) { - clearTimeout(this.quickStartTutorialTimerId); + scheduler.clearTimeout(this.quickStartTutorialTimerId); this.quickStartTutorialTimerId = null; } this.quickStartTutorialReady = false; // Only start the timer if the tutorial should be shown if (this.shouldShowQuickStartTutorial) { - this.quickStartTutorialTimerId = setTimeout(() => { + this.quickStartTutorialTimerId = scheduler.setTimeout(() => { this.quickStartTutorialReady = true; }, 6000); // 6 seconds } From e230fc74c88098a17e4cc0c1ce3302c117c32494 Mon Sep 17 00:00:00 2001 From: Jan Francis Berdan Date: Thu, 15 Jan 2026 11:00:26 +0800 Subject: [PATCH 02/20] fix: updated minigame animation to use gameplay-scene deltaTime instead of own --- src/miniGame/miniGames/miniGameHandler.ts | 8 +- .../miniGames/treasureChest/treasureChest.ts | 29 +++-- .../treasureChest/treasureChestAnimation.ts | 120 +++++------------- .../treasureChestMiniGame.spec.ts | 2 +- .../treasureChest/treasureChestMiniGame.ts | 5 +- .../miniGames/treasureChest/treasureStones.ts | 13 +- .../gameplay-scene/gameplay-flow-manager.ts | 2 +- src/scenes/gameplay-scene/gameplay-scene.ts | 1 + 8 files changed, 67 insertions(+), 113 deletions(-) diff --git a/src/miniGame/miniGames/miniGameHandler.ts b/src/miniGame/miniGames/miniGameHandler.ts index 1c1a21172..fdfef0124 100644 --- a/src/miniGame/miniGames/miniGameHandler.ts +++ b/src/miniGame/miniGames/miniGameHandler.ts @@ -65,9 +65,13 @@ export class MiniGameHandler { * - May include conditional drawing logic depending on * mini-game type or state. */ - public draw() { + public start() { //Draw mini game. - this.activeMiniGame?.draw(); + this.activeMiniGame?.start(); + } + + public update(deltaTime: number) { + this.activeMiniGame?.update(deltaTime); } private handleMiniGameComplete(earnedStarCount: number) { diff --git a/src/miniGame/miniGames/treasureChest/treasureChest.ts b/src/miniGame/miniGames/treasureChest/treasureChest.ts index 6703b5ecc..c78748ba8 100644 --- a/src/miniGame/miniGames/treasureChest/treasureChest.ts +++ b/src/miniGame/miniGames/treasureChest/treasureChest.ts @@ -8,16 +8,13 @@ export default class TreasureChest { private closedChestImg: HTMLImageElement; // Image for closed chest state private openChestImg: HTMLImageElement; // Image for open chest state private ctx: CanvasRenderingContext2D; // Canvas context used for drawing - private lastSpawnTime: number; // Timestamp of the last chest spawn public shakeDuration: number = 1000; // 1s; Default seconds of shaking animation. /** * @param ctx - Canvas rendering context to draw the chest - * @param lastSpawnTime - The time when the chest was spawned (used for animations) */ - constructor(ctx: CanvasRenderingContext2D, lastSpawnTime: number) { + constructor(ctx: CanvasRenderingContext2D) { this.ctx = ctx; - this.lastSpawnTime = lastSpawnTime; // Load chest images this.closedChestImg = new Image(); this.closedChestImg.src = CLOSED_CHEST; // Path to closed chest asset @@ -44,14 +41,22 @@ export default class TreasureChest { const chestY = height - chestH - 20; // Pulse/rotate chest if just spawned (< 1s old) - const elapsed = time - this.lastSpawnTime; + // For pulse, we use the time since state started (which is passed as time, if stateTimer is passed) + // Wait, drawClosedChest is called with (time, stateStartTime). + // In TreasureChestAnimation, we will pass (stateTimer, 0) effectively if we want relative time. + + // Let's assume 'time' passed here is the accumulated state timer. + // The previous code used `elapsed = time - lastSpawnTime`. + // Since we want the pulse to happen at the start of the interaction/animation, + // and `TreasureChestAnimation` handles states, we can assume `time` IS the elapsed time in the current state + // OR `time` is the total elapsed time of the minigame. + + // If TreasureChestAnimation passes `stateTimer` as `time`, then `elapsed` is just `time`. + + const elapsed = time; let scale = 1; let rotation = 0; - if (elapsed < 1000) { - scale = 1 + Math.sin((elapsed / 1000) * Math.PI * 4) * 0.05; - rotation = Math.sin((elapsed / 1000) * Math.PI * 6) * 0.1; - } - + this.ctx.save(); this.ctx.translate(chestX + chestW / 2, chestY + chestH / 2); this.ctx.rotate(rotation); @@ -78,8 +83,8 @@ export default class TreasureChest { /** * Calculates horizontal shake offset based on elapsed animation time. * Produces a left-right jitter effect for a limited duration. - * @param now - Current timestamp - * @param stateStartTime - Timestamp when chest entered shaking state + * @param now - Current timestamp (stateTimer) + * @param stateStartTime - Timestamp when chest entered shaking state (0 in relative mode) * @returns number - Offset in pixels to apply */ private getShakeOffset(now: number, stateStartTime: number): number { diff --git a/src/miniGame/miniGames/treasureChest/treasureChestAnimation.ts b/src/miniGame/miniGames/treasureChest/treasureChestAnimation.ts index 69a5f88f0..7c8c6ce76 100644 --- a/src/miniGame/miniGames/treasureChest/treasureChestAnimation.ts +++ b/src/miniGame/miniGames/treasureChest/treasureChestAnimation.ts @@ -25,25 +25,21 @@ enum TreasureChestState { export class TreasureChestAnimation { private canvas: HTMLCanvasElement; // Canvas element used for drawing private ctx: CanvasRenderingContext2D; // 2D rendering context - private animationFrameId: number | null = null; // requestAnimationFrame id for cancelling loop private isVisible: boolean = false; // Whether the animation is currently active private callback: () => void; // Callback triggered when a stone is clicked private lastTapTime = 0; // Used to prevent duplicate clicks on mobile - private fadeInStart: number | null = null; // Fade-in start timestamp - private fadeOutStart: number | null = null; // Fade-out start timestamp private fadeInDuration = 300; // Fade-in time (ms) private fadeOutDuration = 400; // Fade-out time (ms) private onFadeComplete?: () => void; // Callback after fade-out completes private state: TreasureChestState = TreasureChestState.FadeIn; // Current chest animation state - private stateStartTime: number = 0; // Timestamp when current state started + private stateTimer: number = 0; // Accumulated time in current state public dpr: number; // Device pixel ratio scaling public audioPlayer: AudioPlayer; private sfxPlayer: AudioPlayer; private chestAudioPlayed: boolean = false; - private lastFrameTime: number = -1; private showBonusStar: boolean = false; // flag for showing Blue Bonus Star - private blueStarStartTime: number = 0; + private blueStarTimer: number = 0; private blueStarDuration: number = 2000; // total visible time (ms) private blueStarFadeIn: number = 400; private blueStarFadeOut: number = 400; @@ -55,8 +51,8 @@ export class TreasureChestAnimation { private blueStarVisible: boolean = false; /** - * @param width - Canvas width in CSS pixels - * @param height - Canvas height in CSS pixels + * @param width - Canvas width + * @param height - Canvas height * @param callback - Called when a stone is clicked */ constructor( @@ -102,13 +98,13 @@ export class TreasureChestAnimation { this.ctx.scale(this.dpr, this.dpr); // scale at init // Initialize chest and stone managers - this.treasureChest = new TreasureChest(this.ctx, performance.now()); + this.treasureChest = new TreasureChest(this.ctx); this.treasureStone = new TreasureStones(this.ctx); // after initializing this.treasureStone this.treasureStone.onBlueBonusReady = () => { this.showBonusStar = true; - this.blueStarStartTime = performance.now(); + this.blueStarTimer = 0; this.blueStarSoundPlayed = false; }; @@ -124,7 +120,7 @@ export class TreasureChestAnimation { // re-hook the callback on the injected instance this.treasureStone.onBlueBonusReady = () => { this.showBonusStar = true; - this.blueStarStartTime = performance.now(); + this.blueStarTimer = 0; this.blueStarSoundPlayed = false; }; } @@ -174,10 +170,11 @@ export class TreasureChestAnimation { } /** Draws the Blue Bonus Star with smooth fade-in/out while game continues */ - private drawBlueBonusStar(time: number) { + private drawBlueBonusStar(deltaTime: number) { if (!this.showBonusStar || !this.blueStarImg.complete) return; - const elapsed = performance.now() - this.blueStarStartTime; + this.blueStarTimer += deltaTime; + const elapsed = this.blueStarTimer; // Ensure duration resets properly when star first visible if (elapsed < 50 && !this.blueStarSoundPlayed) { @@ -210,39 +207,22 @@ export class TreasureChestAnimation { this.ctx.restore(); } - - /** Starts the animation and displays the canvas overlay. */ public show(onComplete?: () => void) { this.canvas.style.display = "block"; this.isVisible = true; this.onFadeComplete = onComplete; this.state = TreasureChestState.FadeIn; - this.stateStartTime = performance.now(); - this.loop(); + this.stateTimer = 0; } /** Blue Bonus Star Animation */ public showBlueBonusStar() { - const duration = 3000; // total duration of one visible cycle - const minInterval = 1000; // min time hidden - const maxInterval = 1000; // max time hidden - - const toggleVisibility = () => { - if (!this.isVisible) return; // stop if chest hidden - this.blueStarVisible = !this.blueStarVisible; - - // Schedule next toggle randomly - const nextDelay = this.blueStarVisible - ? duration // show duration - : Math.random() * (maxInterval - minInterval) + minInterval; // hide duration - setTimeout(toggleVisibility, nextDelay); - }; - - toggleVisibility(); // start toggling + this.showBonusStar = true; + this.blueStarTimer = 0; + this.blueStarSoundPlayed = false; }; - /** Stops animation, hides canvas, and cleans up listeners. */ public hide() { this.audioPlayer.stopAudio(); @@ -250,54 +230,50 @@ export class TreasureChestAnimation { this.canvas.removeEventListener("click", this.handleClick); this.canvas.removeEventListener("touchstart", this.handleClick); this.isVisible = false; - if (this.animationFrameId) { - cancelAnimationFrame(this.animationFrameId); - this.animationFrameId = null; - } } /** * Main animation loop. * Runs via requestAnimationFrame while visible. */ - private loop = (time: number = 0) => { + public draw(deltaTime: number) { if (!this.isVisible) return; - const deltaTime = this.lastFrameTime < 0 ? 0 : time - this.lastFrameTime; - this.lastFrameTime = time; + this.stateTimer += deltaTime; + this.ctx.clearRect(0, 0, this.width, this.height); this.ctx.save(); this.drawOverlay(); switch (this.state) { case TreasureChestState.FadeIn: { - const elapsed = performance.now() - this.stateStartTime; + const elapsed = this.stateTimer; const alpha = Math.min(1, elapsed / this.fadeInDuration); this.ctx.globalAlpha = alpha; this.treasureChest.drawClosedChest( - time, - this.stateStartTime, + this.stateTimer, + 0, this.width, this.height ); if (alpha >= 1) { this.state = TreasureChestState.ClosedChest; - this.stateStartTime = performance.now(); + this.stateTimer = 0; } break; } case TreasureChestState.ClosedChest: { this.ctx.globalAlpha = 1; this.treasureChest.drawClosedChest( - time, - this.stateStartTime, + this.stateTimer, + 0, this.width, this.height ); - const elapsed = performance.now() - this.stateStartTime; + const elapsed = this.stateTimer; if (elapsed >= this.treasureChest.shakeDuration) { this.state = TreasureChestState.OpenedChest; - this.stateStartTime = performance.now(); + this.stateTimer = 0; } break; } @@ -314,24 +290,24 @@ export class TreasureChestAnimation { } // Draw Blue Star (animated) - this.drawBlueBonusStar(time); + this.drawBlueBonusStar(deltaTime); - const elapsed = performance.now() - this.stateStartTime; + const elapsed = this.stateTimer; if (elapsed >= 12000) { this.state = TreasureChestState.FadeOut; - this.stateStartTime = performance.now(); + this.stateTimer = 0; } break; } case TreasureChestState.FadeOut: { - const elapsed = performance.now() - this.stateStartTime; + const elapsed = this.stateTimer; const alpha = Math.max(0, 1 - elapsed / this.fadeOutDuration); this.ctx.globalAlpha = alpha; this.treasureChest.drawOpenChest(this.width, this.height); this.treasureStone.stoneBurstAnimation(this.width, this.height, deltaTime); //Keep showing Blue Star if still active - this.drawBlueBonusStar(time); + this.drawBlueBonusStar(deltaTime); if (alpha === 0) { this.hide(); @@ -344,45 +320,11 @@ export class TreasureChestAnimation { } this.ctx.restore(); - this.animationFrameId = requestAnimationFrame(this.loop); }; - /** Handles fade-in alpha calculation (unused alternative). */ - private handleFadeIn(): boolean { - if (!this.fadeInStart) return false; - - const elapsed = performance.now() - this.fadeInStart; - const alpha = Math.min(1, elapsed / this.fadeInDuration); - - this.ctx.globalAlpha = alpha; - - if (alpha >= 1) { - this.fadeInStart = null; // fade-in complete - } - return true; - } - - /** Handles fade-out alpha calculation (unused alternative). */ - private handleFadeOut(): boolean { - if (!this.fadeOutStart) return false; - const elapsed = performance.now() - this.fadeOutStart; - const alpha = Math.max(0, 1 - elapsed / this.fadeOutDuration); - - this.ctx.globalAlpha = alpha; - - if (alpha === 0) { - this.hide(); - this.onFadeComplete?.(); - this.onFadeComplete = undefined; - return true; // stop loop early - } - return true; - } - /** Draws a semi-transparent black overlay behind chest/stones. */ private drawOverlay() { this.ctx.fillStyle = "rgba(0,0,0,0.7)"; this.ctx.fillRect(0, 0, this.width, this.height); } - -} +} \ No newline at end of file diff --git a/src/miniGame/miniGames/treasureChest/treasureChestMiniGame.spec.ts b/src/miniGame/miniGames/treasureChest/treasureChestMiniGame.spec.ts index 406de8dd1..5e125030f 100644 --- a/src/miniGame/miniGames/treasureChest/treasureChestMiniGame.spec.ts +++ b/src/miniGame/miniGames/treasureChest/treasureChestMiniGame.spec.ts @@ -38,7 +38,7 @@ describe('Testing TreasureChestMiniGame.', () => { miniGame['collectedBeforeThreshold'] = 3; // Run draw to trigger animation - miniGame.draw(); + miniGame.start(); // After animation completes, processStoneCollection should run and call the callback expect(mockCallback).toHaveBeenCalledWith(1); diff --git a/src/miniGame/miniGames/treasureChest/treasureChestMiniGame.ts b/src/miniGame/miniGames/treasureChest/treasureChestMiniGame.ts index 93f1d1aa5..d6e5515c0 100644 --- a/src/miniGame/miniGames/treasureChest/treasureChestMiniGame.ts +++ b/src/miniGame/miniGames/treasureChest/treasureChestMiniGame.ts @@ -82,7 +82,7 @@ export class TreasureChestMiniGame { //Draw logic for treasure chest minigame. Called by mini game handler. - public draw() { + public start() { if (!this.miniGameStatus) { const totalGameDuration = 12000; // 12 seconds total this.treasureStones.startTimer(totalGameDuration); @@ -92,7 +92,10 @@ export class TreasureChestMiniGame { this.processStoneCollection(); }); } + } + public update(deltaTime: number) { + this.treasureAnimation.draw(deltaTime); } //Called before moving to level-end scene. This is handled and used by mini game handler. diff --git a/src/miniGame/miniGames/treasureChest/treasureStones.ts b/src/miniGame/miniGames/treasureChest/treasureStones.ts index a3c85ce16..e21b990e5 100644 --- a/src/miniGame/miniGames/treasureChest/treasureStones.ts +++ b/src/miniGame/miniGames/treasureChest/treasureStones.ts @@ -31,7 +31,7 @@ export default class TreasureStones { private burnFrames: HTMLImageElement[] = []; // Preloaded burn animation frames private ctx: CanvasRenderingContext2D; // Canvas rendering context private frameDuration: number; //Frame duration. - private startTime: number = 0; // track when the minigame started + private elapsedTime: number = 0; // track when the minigame started private totalDuration: number = 12000; // default duration in ms (can be updated externally) private totalSpawned = 0; private exitedCount = 0; @@ -109,15 +109,13 @@ export default class TreasureStones { /** Call this when minigame starts */ public startTimer(totalDurationMs: number) { - this.startTime = performance.now(); + this.elapsedTime = 0; this.totalDuration = totalDurationMs; } /** Returns elapsed time percent (0–100) */ private getElapsedPercent(): number { - if (!this.startTime) return 0; - const elapsed = performance.now() - this.startTime; - return Math.min((elapsed / this.totalDuration) * 100, 100); + return Math.min((this.elapsedTime / this.totalDuration) * 100, 100); } private logSpawnPercentAndMaybeTrigger() { @@ -154,6 +152,7 @@ export default class TreasureStones { * @param height - Canvas height */ public stoneBurstAnimation(width: number, height: number, deltaTime: number): void { + this.elapsedTime += deltaTime; this.updateStones(width, deltaTime); this.cleanupStones(); this.maintainStones(width, height); @@ -271,7 +270,7 @@ export default class TreasureStones { if (stone.burning) return false; // Trigger burn sequence stone.burning = true; - stone.burnStartTime = performance.now(); + stone.burnStartTime = this.elapsedTime; stone.burnFrameIndex = 0; // Play burn SFX @@ -296,7 +295,7 @@ export default class TreasureStones { * Marks the stone inactive once the animation finishes. */ private updateBurningStone(stone: Stone): void { - const elapsed = performance.now() - (stone.burnStartTime || 0); + const elapsed = this.elapsedTime - (stone.burnStartTime || 0); stone.burnFrameIndex = Math.floor(elapsed / this.frameDuration); if (stone.burnFrameIndex >= this.burnFrames.length) stone.active = false; } diff --git a/src/scenes/gameplay-scene/gameplay-flow-manager.ts b/src/scenes/gameplay-scene/gameplay-flow-manager.ts index 1b8b893d1..68b479a4f 100644 --- a/src/scenes/gameplay-scene/gameplay-flow-manager.ts +++ b/src/scenes/gameplay-scene/gameplay-flow-manager.ts @@ -113,7 +113,7 @@ export class GameplayFlowManager { ); // Run chest animation (mini game) - this.miniGameHandler.draw(); + this.miniGameHandler.start(); return; } else { // For incorrect answers only; Start loading the next puzzle with 2 seconds delay to let the audios play. diff --git a/src/scenes/gameplay-scene/gameplay-scene.ts b/src/scenes/gameplay-scene/gameplay-scene.ts index a380d0a66..82895a973 100644 --- a/src/scenes/gameplay-scene/gameplay-scene.ts +++ b/src/scenes/gameplay-scene/gameplay-scene.ts @@ -323,6 +323,7 @@ export class GameplayScene { } } + this.miniGameHandler.update(deltaTime); this.tutorial.draw(deltaTime, this.isGameStarted); } From f959303036c1ff36151e50e516491c4e800637b9 Mon Sep 17 00:00:00 2001 From: Jan Francis Berdan Date: Fri, 16 Jan 2026 11:38:19 +0800 Subject: [PATCH 03/20] feat: added playUIAudio in the audio-player to separate nonPausable audio from pausable --- src/components/audio-player.ts | 53 ++++++++++++------- .../pause-popup/pause-popup-component.ts | 2 +- 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/src/components/audio-player.ts b/src/components/audio-player.ts index c1f2bee8f..274e7e141 100644 --- a/src/components/audio-player.ts +++ b/src/components/audio-player.ts @@ -7,6 +7,7 @@ export class AudioPlayer { public static instance: AudioPlayer; private audioContext: AudioContext | null; + private nonPausableAudioContext: AudioContext | null; private sourceNode: AudioBufferSourceNode | null; private audioQueue: string[]; @@ -23,6 +24,7 @@ export class AudioPlayer { return AudioPlayer.instance; AudioPlayer.instance = this; this.audioContext = AudioContextManager.getAudioContext(); + this.nonPausableAudioContext = AudioContextManager.getNonPausableAudioContext(); this.sourceNode = null; this.audioQueue = []; this.clickSoundBuffer = null; // Initialize the clickSoundBuffer @@ -31,30 +33,32 @@ export class AudioPlayer { } - async playButtonClickSound() { - const audioSrc: string = AUDIO_PATH_BTN_CLICK; - - if (!this.isClickSoundLoaded) { - // Load and decode the audio on demand if it hasn't been loaded - try { - this.clickSoundBuffer = await this.loadAndDecodeAudio(audioSrc); - AudioPlayer.audioBuffers.set(audioSrc, this.clickSoundBuffer); - this.isClickSoundLoaded = true; // Set the flag to true after loading - } catch (error) { - console.error("Error loading or decoding click sound:", error); - return; + playUIAudio(audioSrc: string, volume: number = 1, onEnded?: () => void) { + const audioBuffer: AudioBuffer = AudioPlayer.audioBuffers.get(audioSrc); + if (audioBuffer) { + const sourceNode = this.nonPausableAudioContext.createBufferSource(); + const gainNode = this.nonPausableAudioContext.createGain(); + sourceNode.buffer = audioBuffer; + sourceNode.connect(gainNode); + gainNode.connect(this.nonPausableAudioContext.destination); + // handle end of playback + if (onEnded) { + sourceNode.onended = onEnded; } - } - // Play the audio using the buffer if it exists - if (this.clickSoundBuffer) { - const sourceNode = this.audioContext!.createBufferSource(); - sourceNode.buffer = this.clickSoundBuffer; - sourceNode.connect(this.audioContext!.destination); + gainNode.gain.value = volume; // Set volume (1 = full, 0.5 = half, etc.) + + this.audioSourcs.push(sourceNode); sourceNode.start(); - } else { - console.error("Click sound buffer is not available."); + + return sourceNode; } + return null; + } + + async playButtonClickSound() { + const audioSrc: string = AUDIO_PATH_BTN_CLICK; + this.playUIAudio(audioSrc); } private async loadAndDecodeAudio(audioSrc: string): Promise { @@ -295,6 +299,7 @@ export class AudioPlayer { class AudioContextManager { private static instance: AudioContext | null = null; + private static nonPausableAudioContext: AudioContext | null = null; static getAudioContext(): AudioContext { if (!AudioContextManager.instance) { AudioContextManager.instance = new (window.AudioContext || @@ -302,4 +307,12 @@ class AudioContextManager { } return AudioContextManager.instance; } + + static getNonPausableAudioContext(): AudioContext { + if (!AudioContextManager.nonPausableAudioContext) { + AudioContextManager.nonPausableAudioContext = new (window.AudioContext || + (window as Window).webkitAudioContext)(); + } + return AudioContextManager.nonPausableAudioContext; + } } \ No newline at end of file diff --git a/src/components/popups/pause-popup/pause-popup-component.ts b/src/components/popups/pause-popup/pause-popup-component.ts index aa4dbc74b..857a73b27 100644 --- a/src/components/popups/pause-popup/pause-popup-component.ts +++ b/src/components/popups/pause-popup/pause-popup-component.ts @@ -40,7 +40,7 @@ export class PausePopupComponent extends BasePopupComponent { this.openConfirm = debounce(() => { this.confirmPopup.open(); - this.audioPlayer.playAudio(AUDIO_ARE_YOU_SURE); + this.audioPlayer.playUIAudio(AUDIO_ARE_YOU_SURE); }, 300); } From a3f34e38e2f819b533ea5bd8108a5e72641dcd1d Mon Sep 17 00:00:00 2001 From: Jan Francis Berdan Date: Tue, 20 Jan 2026 15:01:39 +0800 Subject: [PATCH 04/20] chore: fixed unit testing affected by scheduler change --- src/components/__mocks__/audio-player.ts | 2 + .../prompt-text/prompt-text.spec.ts | 1 + src/components/timerHtml/timerHtml.spec.ts | 16 +++-- .../gameplay-scene/gameplay-scene.spec.ts | 61 +++++++++++++------ 4 files changed, 56 insertions(+), 24 deletions(-) diff --git a/src/components/__mocks__/audio-player.ts b/src/components/__mocks__/audio-player.ts index 678317fda..bba3ed62c 100644 --- a/src/components/__mocks__/audio-player.ts +++ b/src/components/__mocks__/audio-player.ts @@ -8,6 +8,8 @@ const mockAudioPlayerInstance = { stopFeedbackAudio: jest.fn(), preloadPromptAudio: jest.fn(), handlePlayPromptAudioClickEvent: jest.fn(), + pauseAllAudios: jest.fn(), + resumeAllAudios: jest.fn(), audioContext: { createBufferSource: jest.fn(() => ({ connect: jest.fn(), diff --git a/src/components/prompt-text/prompt-text.spec.ts b/src/components/prompt-text/prompt-text.spec.ts index 52b4fc15f..a02bcb5ea 100644 --- a/src/components/prompt-text/prompt-text.spec.ts +++ b/src/components/prompt-text/prompt-text.spec.ts @@ -142,6 +142,7 @@ describe('generateTextMarkup', () => { describe('generatePromptSlots', () => { it('should generate underscore slots after GAME_HAS_STARTED event', () => { jest.useFakeTimers(); // ✅ use fake timers + jest.clearAllMocks(); promptText = new PromptText( 500, puzzleDataMock, diff --git a/src/components/timerHtml/timerHtml.spec.ts b/src/components/timerHtml/timerHtml.spec.ts index 84c4f6227..1f7fce065 100644 --- a/src/components/timerHtml/timerHtml.spec.ts +++ b/src/components/timerHtml/timerHtml.spec.ts @@ -3,6 +3,7 @@ import { AUDIO_TIMEOUT } from '@constants'; import TimerTicking from '../timer-ticking'; import TimerHTMLComponent from './timerHtml'; import { AudioPlayer } from '@components'; +import scheduler from '../../services/scheduler'; // Mock dependencies jest.mock('./timerHtml', () => { @@ -41,11 +42,12 @@ describe('TimerTicking', () => { afterEach(() => { jest.clearAllMocks(); document.body.innerHTML = ''; // Clean up DOM + scheduler.destroy(); }); - test('should initialize TimerHTMLComponent and set timerFullContainer', async () => { - // Wait for the asynchronous code inside the constructor to finish - await new Promise((resolve) => setTimeout(resolve, 0)); // Wait for the setTimeout to execute + test('should initialize TimerHTMLComponent and set timerFullContainer', () => { + // Trigger scheduler to run the setTimeout callback + scheduler.update(1); expect(TimerHTMLComponent).toHaveBeenCalledWith('timer-ticking'); expect(timerTicking.timerFullContainer).not.toBeNull(); @@ -53,6 +55,7 @@ describe('TimerTicking', () => { }); test('should start the timer and reset width', () => { + scheduler.update(1); // Ensure initialization const spyReadyTimer = jest.spyOn(timerTicking, 'readyTimer'); timerTicking.startTimer(); @@ -63,6 +66,7 @@ describe('TimerTicking', () => { }); test('should update the timer and reduce the width', () => { + scheduler.update(1); // Ensure initialization const deltaTime = 16; // Simulate a frame duration timerTicking.startTimer(); timerTicking.update(deltaTime); @@ -72,6 +76,7 @@ describe('TimerTicking', () => { }); test('should call callback, update timer width, and play audio when timer is nearly depleted and over', () => { + scheduler.update(1); // Ensure initialization const deltaTime = 20000; // Simulate a large frame duration to deplete the timer timerTicking.startTimer(); // Start the timer @@ -102,9 +107,8 @@ describe('TimerTicking', () => { } }); - test('should handle stone drop and stop timer updates', async () => { - // Wait for the constructor code to execute - await new Promise((resolve) => setTimeout(resolve, 0)); + test('should handle stone drop and stop timer updates', () => { + scheduler.update(1); // Ensure initialization // Start the timer timerTicking.startTimer(); diff --git a/src/scenes/gameplay-scene/gameplay-scene.spec.ts b/src/scenes/gameplay-scene/gameplay-scene.spec.ts index 8a08d8503..e431a503d 100644 --- a/src/scenes/gameplay-scene/gameplay-scene.spec.ts +++ b/src/scenes/gameplay-scene/gameplay-scene.spec.ts @@ -33,6 +33,7 @@ import gameSettingsService from '@gameSettingsService'; import miniGameStateService from '@miniGameStateService' import { SCENE_NAME_GAME_PLAY } from "@constants"; import { AnalyticsIntegration } from '../../analytics/analytics-integration'; +import scheduler from '../../services/scheduler'; // --- IMPORTANT: All mocks must be defined BEFORE imports to ensure proper isolation --- // Mock Rive (prevents any real Rive/WebGL code from running in Jest) @@ -70,6 +71,8 @@ jest.mock('@tutorials', () => { resetTutorialTimer: jest.fn(), resetQuickStartTutorialDelay: jest.fn(), draw: jest.fn(), + handleTutorialAndGameStart: jest.fn(), + updateTutorialTimer: jest.fn(), // Add any other methods/properties your tests might access })), getGameTypeName: jest.fn(() => 'Soundundefined'), @@ -123,7 +126,8 @@ jest.mock('@components', () => { targetStones: [], stonePos: { x: 0, y: 0 }, pickedStone: null, - playDragAudioIfNecessary: jest.fn() + playDragAudioIfNecessary: jest.fn(), + stonesHasLoaded: true, })), PromptText: jest.fn().mockImplementation(() => ({ draw: jest.fn(), @@ -182,6 +186,8 @@ jest.mock('./monster-controller', () => ({ getRiveInstance: jest.fn().mockReturnValue({}), checkHitbox: jest.fn().mockReturnValue(false), onClick: jest.fn().mockReturnValue(false), + pause: jest.fn(), + resume: jest.fn(), get currentPhase() { return 3; } })), })); @@ -243,6 +249,7 @@ jest.mock('@miniGameStateService', () => ({ publish: jest.fn(), EVENTS: { IS_MINI_GAME_DONE: 'IS_MINI_GAME_DONE', + MINI_GAME_WILL_START: 'MINI_GAME_WILL_START' }, shouldShowMiniGame: jest.fn(), } @@ -332,18 +339,23 @@ describe('GameplayScene with BasePopupComponent', () => { ...gameplayScene.tutorial, resetQuickStartTutorialDelay: jest.fn(), showHandPointerInAudioPuzzle: jest.fn().mockReturnValue(false), + handleTutorialAndGameStart: jest.fn(), + updateTutorialTimer: jest.fn(), + draw: jest.fn(), + dispose: jest.fn(), + hideTutorial: jest.fn(), + resetTutorialTimer: jest.fn(), // Add any other required methods here } as any as any; }); afterEach(() => { - jest.clearAllMocks(); - document.body.innerHTML = ''; - // Ensure any leftover listeners are removed to prevent side effects if (gameplayScene) { - // Manually remove listener in case dispose() failed or wasn't called - document.removeEventListener('visibilitychange', gameplayScene.handleVisibilityChange, false); + gameplayScene.dispose(); } + jest.clearAllMocks(); + document.body.innerHTML = ''; + scheduler.destroy(); }); it('should call switchSceneToEnd immediately (0ms) when timerEnded is true or !isFeedBackTriggered is true', () => { @@ -355,9 +367,8 @@ describe('GameplayScene with BasePopupComponent', () => { // Act (gameplayScene.flowManager as any).loadPuzzle(timerEnded, 4500); - // Force immediate execution of timers - jest.runAllTimers(); - + // Trigger scheduler update + scheduler.update(100); // Assert expect(gameStateService.publish).toHaveBeenCalledWith( @@ -378,8 +389,8 @@ describe('GameplayScene with BasePopupComponent', () => { // Assert: Ensure it is not called immediately expect(mockSwitchSceneToEnd).not.toHaveBeenCalled(); - // Advance time by 4500ms - jest.advanceTimersByTime(4500); + // Advance scheduler + scheduler.update(4500); // Assert expect(gameStateService.publish).toHaveBeenCalledWith( @@ -404,7 +415,9 @@ describe('GameplayScene with BasePopupComponent', () => { triggerMonsterAnimation('isSad'); } - jest.runAllTimers(); + // jest.runAllTimers(); // No longer needed as we mock scheduler but here triggerMonsterAnimation seems direct. + // Assuming triggerMonsterAnimation uses scheduler internally? No, looking at monster-controller logic (not visible here but usually direct or rive event). + // The test just checks if it was called. expect(gameplayScene.monsterController.triggerMonsterAnimation).toHaveBeenCalledWith('isChewing'); expect(gameplayScene.monsterController.triggerMonsterAnimation).toHaveBeenCalledWith('isHappy'); @@ -420,11 +433,11 @@ describe('GameplayScene with BasePopupComponent', () => { (gameplayScene.flowManager as any).loadPuzzle(timerEnded, 4500); // Assert: Ensure no call before 4500ms - jest.advanceTimersByTime(4499); + scheduler.update(4400); expect(mockSwitchSceneToEnd).not.toHaveBeenCalled(); // Advance time to 4500ms - jest.advanceTimersByTime(1); + scheduler.update(100); // Assert: Now it should be called expect(gameStateService.publish).toHaveBeenCalledWith( @@ -464,6 +477,7 @@ describe('GameplayScene with BasePopupComponent', () => { if (event === gameStateService.EVENTS.GAME_PAUSE_STATUS_EVENT) { pauseStatusCallback.mockImplementation(cb); } + return jest.fn(); // Return unsubscribe function to prevent error }); // Re-initialize the scene to register subscriptions @@ -559,14 +573,19 @@ describe('GameplayScene with BasePopupComponent', () => { describe('Event Handling', () => { it('should handle visibility change event', () => { const mockAudioPlayer = { - stopAllAudios: jest.fn() + stopAllAudios: jest.fn(), + pauseAllAudios: jest.fn() // ✅ Added pauseAllAudios }; gameplayScene.audioPlayer = mockAudioPlayer as any; // Simulate visibility change document.dispatchEvent(new Event('visibilitychange')); - expect(mockAudioPlayer.stopAllAudios).toHaveBeenCalled(); + // expect(mockAudioPlayer.stopAllAudios).toHaveBeenCalled(); // This was original expectation, but pauseGamePlay calls pauseAllAudios + // Since pauseGamePlay calls pauseAllAudios, we should expect that. + // And pauseGamePlay is called by handleVisibilityChange. + expect(mockAudioPlayer.pauseAllAudios).toHaveBeenCalled(); + expect(gameStateService.publish).toHaveBeenCalledWith( gameStateService.EVENTS.GAME_PAUSE_STATUS_EVENT, true @@ -578,7 +597,8 @@ describe('GameplayScene with BasePopupComponent', () => { // Mock the pause popup component inside UI manager const mockPausePopup = { - onClose: jest.fn() + onClose: jest.fn(), + destroy: jest.fn() // ✅ Added destroy }; gameplayScene.uiManager.pausePopupComponent = mockPausePopup as any; @@ -614,12 +634,17 @@ describe('GameplayScene with BasePopupComponent', () => { stonesHasLoaded: true, stones: [mockStone], draw: jest.fn(), // stubbed to avoid internal errors + dispose: jest.fn(), // ✅ Added dispose }; + // Ensure stoneHandler.drawWordPuzzleLetters exists if it's called + (gameplayScene as any).stoneHandler.drawWordPuzzleLetters = jest.fn(); + (gameplayScene as any).tutorial = { handleTutorialAndGameStart: jest.fn(), draw: jest.fn(), updateTutorialTimer: jest.fn(), // <-- This is the key addition! + dispose: jest.fn(), // Added dispose }; (gameplayScene as any).isPauseButtonClicked = false; @@ -634,4 +659,4 @@ describe('GameplayScene with BasePopupComponent', () => { expect(mockUiUpdate).toHaveBeenCalledWith(16, true, false, true); }); }); -}); \ No newline at end of file +}); From f2b7b4dce585d0d459a4e8d112ea0b99ecc5bb26 Mon Sep 17 00:00:00 2001 From: Jan Francis Berdan Date: Tue, 20 Jan 2026 15:03:59 +0800 Subject: [PATCH 05/20] chore: removed comments --- src/scenes/gameplay-scene/gameplay-scene.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/scenes/gameplay-scene/gameplay-scene.spec.ts b/src/scenes/gameplay-scene/gameplay-scene.spec.ts index e431a503d..28dfc11ce 100644 --- a/src/scenes/gameplay-scene/gameplay-scene.spec.ts +++ b/src/scenes/gameplay-scene/gameplay-scene.spec.ts @@ -574,7 +574,7 @@ describe('GameplayScene with BasePopupComponent', () => { it('should handle visibility change event', () => { const mockAudioPlayer = { stopAllAudios: jest.fn(), - pauseAllAudios: jest.fn() // ✅ Added pauseAllAudios + pauseAllAudios: jest.fn() }; gameplayScene.audioPlayer = mockAudioPlayer as any; @@ -598,7 +598,7 @@ describe('GameplayScene with BasePopupComponent', () => { // Mock the pause popup component inside UI manager const mockPausePopup = { onClose: jest.fn(), - destroy: jest.fn() // ✅ Added destroy + destroy: jest.fn() }; gameplayScene.uiManager.pausePopupComponent = mockPausePopup as any; @@ -634,7 +634,7 @@ describe('GameplayScene with BasePopupComponent', () => { stonesHasLoaded: true, stones: [mockStone], draw: jest.fn(), // stubbed to avoid internal errors - dispose: jest.fn(), // ✅ Added dispose + dispose: jest.fn(), }; // Ensure stoneHandler.drawWordPuzzleLetters exists if it's called @@ -644,7 +644,7 @@ describe('GameplayScene with BasePopupComponent', () => { handleTutorialAndGameStart: jest.fn(), draw: jest.fn(), updateTutorialTimer: jest.fn(), // <-- This is the key addition! - dispose: jest.fn(), // Added dispose + dispose: jest.fn(), }; (gameplayScene as any).isPauseButtonClicked = false; From ea939ee9f10adfac5a3dc6061a428972fdb4f4ce Mon Sep 17 00:00:00 2001 From: Jan Francis Berdan Date: Tue, 20 Jan 2026 15:10:28 +0800 Subject: [PATCH 06/20] chore: added comments regarding scheduler class --- src/services/scheduler.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/services/scheduler.ts b/src/services/scheduler.ts index 84454dbe7..4911f797c 100644 --- a/src/services/scheduler.ts +++ b/src/services/scheduler.ts @@ -12,9 +12,20 @@ type Timer = { let nextTimerId = 0; +/** + * Custom scheduler for managing time-based events within the game loop. + * Unlike standard window.setTimeout/setInterval, this respects the game's + * pause state and is driven by deltaTime from the main update loop. + */ class Scheduler { private timers: Map = new Map(); + /** + * Schedules a one-time callback after a specified delay. + * @param callback The function to execute. + * @param delay Delay in milliseconds. + * @returns A unique TimerId for clearing the timeout. + */ setTimeout(callback: () => void, delay: number): TimerId { const id = nextTimerId++ as TimerId; this.timers.set(id, { @@ -27,12 +38,22 @@ class Scheduler { return id; } + /** + * Cancels a previously scheduled timeout. + * @param id The ID of the timer to clear. + */ clearTimeout(id: TimerId): void { if (id !== undefined && id !== null) { this.timers.delete(id); } } + /** + * Schedules a repeating callback at a specified interval. + * @param callback The function to execute. + * @param delay Interval in milliseconds. + * @returns A unique TimerId for clearing the interval. + */ setInterval(callback: () => void, delay: number): TimerId { const id = nextTimerId++ as TimerId; this.timers.set(id, { @@ -45,10 +66,19 @@ class Scheduler { return id; } + /** + * Cancels a previously scheduled interval. + * @param id The ID of the timer to clear. + */ clearInterval(id: TimerId): void { this.clearTimeout(id); } + /** + * Updates all active timers by subtracting the provided delta time. + * Executed by the main game loop. + * @param delta The time elapsed since the last update in milliseconds. + */ update(delta: number): void { for (const timer of this.timers.values()) { timer.remaining -= delta; @@ -68,6 +98,9 @@ class Scheduler { } } + /** + * Clears all active timers and resets the scheduler state. + */ destroy(): void { this.timers.clear(); } From 50e743eb39e8d3272889a7d67ba7fdd41f5a8f45 Mon Sep 17 00:00:00 2001 From: Jan Francis Berdan Date: Wed, 21 Jan 2026 12:13:22 +0800 Subject: [PATCH 07/20] fix: removed visibility change handling on stone-handler and prompt-text --- src/components/prompt-text/prompt-text.ts | 4 ---- src/components/stone-handler/stone-handler.ts | 17 ----------------- 2 files changed, 21 deletions(-) diff --git a/src/components/prompt-text/prompt-text.ts b/src/components/prompt-text/prompt-text.ts index 1d3ebbb80..b7ebbbbf4 100644 --- a/src/components/prompt-text/prompt-text.ts +++ b/src/components/prompt-text/prompt-text.ts @@ -614,9 +614,5 @@ export class PromptText extends BaseHTML { handleVisibilityChange = () => { const isVisible = document.visibilityState === "visible"; this.isAppForeground = isVisible; - - if (!isVisible) { - this.audioPlayer.stopAllAudios(); - } } } \ No newline at end of file diff --git a/src/components/stone-handler/stone-handler.ts b/src/components/stone-handler/stone-handler.ts index 1ef8a46e9..9bf617a05 100644 --- a/src/components/stone-handler/stone-handler.ts +++ b/src/components/stone-handler/stone-handler.ts @@ -57,11 +57,6 @@ export default class StoneHandler { this.createStones(this.stonebg); }; this.audioPlayer = new AudioPlayer(); - document.addEventListener( - VISIBILITY_CHANGE, - this.handleVisibilityChange, - false - ); this.addEventListeners(); } @@ -235,19 +230,11 @@ export default class StoneHandler { this.canvas.width = this.originalWidth; this.canvas.height = this.originalHeight; this.eventListeners = unsubscribeAll(this.eventListeners); - document.removeEventListener( - VISIBILITY_CHANGE, - this.handleVisibilityChange, - false - ); } public cleanup() { // Clean up audio resources this.disposeStones(); - - // Remove event listeners - document.removeEventListener(VISIBILITY_CHANGE, this.handleVisibilityChange); } public getCorrectTargetStone(): string { @@ -292,10 +279,6 @@ export default class StoneHandler { this.foilStones = []; } - handleVisibilityChange = () => { - this.audioPlayer.stopAllAudios(); - }; - /** * Performance optimization: Parallel audio playback * Disposes stones immediately while playing audio in parallel From d767eef7dac5d38980ef81dda3c7c0b79fec0987 Mon Sep 17 00:00:00 2001 From: Jan Francis Berdan Date: Thu, 22 Jan 2026 14:18:56 +0800 Subject: [PATCH 08/20] fix: fix for Timer Rotation not resuming after pausing before the timer starts. (FM-817) --- src/components/timer-ticking.ts | 4 +--- src/scenes/gameplay-scene/gameplay-scene.ts | 1 - 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/components/timer-ticking.ts b/src/components/timer-ticking.ts index ddff62ef4..098098b20 100644 --- a/src/components/timer-ticking.ts +++ b/src/components/timer-ticking.ts @@ -128,7 +128,7 @@ export default class TimerTicking { const element = document.getElementById("rotating-clock"); if (!element) return; - if (condition) { + if (condition && !this.isStoneDropped && !this.isMyTimerOver) { // Resume rotation - use CSS animation-play-state for seamless resumption element.style.animationPlayState = "running"; if (!element.style.animation || element.style.animation === "none") { @@ -138,8 +138,6 @@ export default class TimerTicking { // Pause rotation - use CSS animation-play-state for seamless pausing if (element.style.animation && element.style.animation !== "none") { element.style.animationPlayState = "paused"; - } else { - element.style.animation = "none"; } } } diff --git a/src/scenes/gameplay-scene/gameplay-scene.ts b/src/scenes/gameplay-scene/gameplay-scene.ts index f5a32495d..39bca89a3 100644 --- a/src/scenes/gameplay-scene/gameplay-scene.ts +++ b/src/scenes/gameplay-scene/gameplay-scene.ts @@ -336,7 +336,6 @@ export class GameplayScene { this.isPaused = false; this.audioPlayer.resumeAllAudios(); // Resume the clock rotation when game is resumed - this.uiManager.applyTimerRotation(this.uiManager.timerTicking?.startMyTimer && !this.uiManager.timerTicking?.isStoneDropped); this.monsterController.resume(); } From d44fafed24a5bf2acca4df2ff689d5088c53c0c5 Mon Sep 17 00:00:00 2001 From: Jan Francis Berdan Date: Thu, 22 Jan 2026 15:58:10 +0800 Subject: [PATCH 09/20] chore: added TODO for scheduler after migrating to Core Modules Renamed clearTimeout to cancelTimeout Removed clearInterval since it is same with cancelTimeout Used cancelTimeout instead of manually deleting timer in update --- src/services/scheduler.ts | 13 +++---------- src/tutorials/index.ts | 2 +- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/services/scheduler.ts b/src/services/scheduler.ts index 4911f797c..e979c43ce 100644 --- a/src/services/scheduler.ts +++ b/src/services/scheduler.ts @@ -16,6 +16,7 @@ let nextTimerId = 0; * Custom scheduler for managing time-based events within the game loop. * Unlike standard window.setTimeout/setInterval, this respects the game's * pause state and is driven by deltaTime from the main update loop. + * TODO: When migrated to Core Modules, integrate with the Core Scheduler. */ class Scheduler { private timers: Map = new Map(); @@ -42,7 +43,7 @@ class Scheduler { * Cancels a previously scheduled timeout. * @param id The ID of the timer to clear. */ - clearTimeout(id: TimerId): void { + cancelTimeout(id: TimerId): void { if (id !== undefined && id !== null) { this.timers.delete(id); } @@ -66,14 +67,6 @@ class Scheduler { return id; } - /** - * Cancels a previously scheduled interval. - * @param id The ID of the timer to clear. - */ - clearInterval(id: TimerId): void { - this.clearTimeout(id); - } - /** * Updates all active timers by subtracting the provided delta time. * Executed by the main game loop. @@ -92,7 +85,7 @@ class Scheduler { if (timer.loop) { timer.remaining += timer.delay; } else { - this.timers.delete(timer.id); + this.cancelTimeout(timer.id); } } } diff --git a/src/tutorials/index.ts b/src/tutorials/index.ts index ed15e0647..28c382462 100644 --- a/src/tutorials/index.ts +++ b/src/tutorials/index.ts @@ -324,7 +324,7 @@ export default class TutorialHandler { public resetQuickStartTutorialDelay() { // Always clear any previous timer to avoid overlap if (this.quickStartTutorialTimerId !== null) { - scheduler.clearTimeout(this.quickStartTutorialTimerId); + scheduler.cancelTimeout(this.quickStartTutorialTimerId); this.quickStartTutorialTimerId = null; } this.quickStartTutorialReady = false; From b74d323d28f5d732367147cd0a9ee4cecf8edb22 Mon Sep 17 00:00:00 2001 From: Jan Francis Berdan Date: Thu, 22 Jan 2026 16:07:00 +0800 Subject: [PATCH 10/20] chore: added unit testing for scheduler --- src/services/scheduler.spec.ts | 68 ++++++++++++++++++++++++++++++++++ src/services/scheduler.ts | 2 +- 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 src/services/scheduler.spec.ts diff --git a/src/services/scheduler.spec.ts b/src/services/scheduler.spec.ts new file mode 100644 index 000000000..394910632 --- /dev/null +++ b/src/services/scheduler.spec.ts @@ -0,0 +1,68 @@ +import scheduler from './scheduler'; + +describe('Scheduler Service', () => { + let callback: jest.Mock; + + beforeEach(() => { + callback = jest.fn(); + scheduler.destroy(); // Ensure clean state before each test + }); + + // Feature: Schedule a one-time task + // Scenario: Execute a callback after a delay + test('should execute a callback after the specified delay', () => { + // Given the scheduler is initialized + // And I verify no timers are initially active (implicit by clean state) + + // When I schedule a task with a delay of 1000ms + const delay = 1000; + scheduler.setTimeout(callback, delay); + + // And I update the scheduler with 500ms + scheduler.update(500); + // Then the callback should not have been called yet + expect(callback).not.toHaveBeenCalled(); + + // And I update the scheduler with another 500ms + scheduler.update(500); + // Then the callback should be called exactly once + expect(callback).toHaveBeenCalledTimes(1); + }); + + // Feature: Schedule a repeating task + // Scenario: Execute a callback repeatedly at intervals + test('should execute a callback repeatedly at the specified interval', () => { + // Given the scheduler is initialized + + // When I schedule a repeating task with an interval of 1000ms + const interval = 1000; + scheduler.setInterval(callback, interval); + + // And I update the scheduler with 1000ms + scheduler.update(1000); + // Then the callback should be called once + expect(callback).toHaveBeenCalledTimes(1); + + // And I update the scheduler with another 1000ms + scheduler.update(1000); + // Then the callback should be called twice + expect(callback).toHaveBeenCalledTimes(2); + }); + + // Feature: Cancel a scheduled task + // Scenario: Cancel a pending timeout + test('should not execute the callback if the timeout is cancelled', () => { + // Given I have scheduled a task with a delay of 1000ms + const delay = 1000; + const timerId = scheduler.setTimeout(callback, delay); + + // When I cancel the task + scheduler.cancelTimeout(timerId); + + // And I update the scheduler with 1000ms + scheduler.update(1000); + + // Then the callback should not be called + expect(callback).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/scheduler.ts b/src/services/scheduler.ts index e979c43ce..6c06cdb25 100644 --- a/src/services/scheduler.ts +++ b/src/services/scheduler.ts @@ -40,7 +40,7 @@ class Scheduler { } /** - * Cancels a previously scheduled timeout. + * Cancels a previously scheduled timeout or interval. * @param id The ID of the timer to clear. */ cancelTimeout(id: TimerId): void { From 380ca1060f57f0d463d5f56a5442a1c3b0256d93 Mon Sep 17 00:00:00 2001 From: Jan Francis Berdan Date: Thu, 22 Jan 2026 16:46:04 +0800 Subject: [PATCH 11/20] chore: moved importing scheduler to its alias @services/scheduler --- src/components/audio-player.ts | 2 +- src/components/prompt-text/prompt-text.ts | 2 +- src/components/timer-ticking.ts | 2 +- src/gamepuzzles/feedbackAudioHandler/feedbackAudioHandler.ts | 2 +- src/gamepuzzles/puzzleHandler/puzzleHandler.ts | 2 +- src/scenes/gameplay-scene/gameplay-flow-manager.ts | 2 +- src/scenes/gameplay-scene/gameplay-scene.ts | 2 +- src/scenes/gameplay-scene/monster-controller.ts | 2 +- src/tutorials/index.ts | 2 +- tsconfig.json | 4 ++-- webpack.config.js | 1 + 11 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/components/audio-player.ts b/src/components/audio-player.ts index 274e7e141..ed6678bfa 100644 --- a/src/components/audio-player.ts +++ b/src/components/audio-player.ts @@ -1,6 +1,6 @@ import { Window } from "@common"; import { AUDIO_PATH_BTN_CLICK } from "@constants"; -import scheduler from "../services/scheduler"; +import scheduler from "@services/scheduler"; export class AudioPlayer { diff --git a/src/components/prompt-text/prompt-text.ts b/src/components/prompt-text/prompt-text.ts index b7ebbbbf4..d1a662d11 100644 --- a/src/components/prompt-text/prompt-text.ts +++ b/src/components/prompt-text/prompt-text.ts @@ -4,7 +4,7 @@ import { PROMPT_TEXT_BG, AUDIO_PLAY_BUTTON } from "@constants"; import { BaseHTML, BaseHtmlOptions } from "../baseHTML/base-html"; import './prompt-text.scss'; import gameStateService from '@gameStateService'; -import scheduler from "../../services/scheduler"; +import scheduler from "@services/scheduler"; // Default selectors for the prompt text component diff --git a/src/components/timer-ticking.ts b/src/components/timer-ticking.ts index 098098b20..4fa7f3db6 100644 --- a/src/components/timer-ticking.ts +++ b/src/components/timer-ticking.ts @@ -3,7 +3,7 @@ import { AudioPlayer } from "@components"; import { TIMER_EMPTY, ROTATING_CLOCK, AUDIO_TIMEOUT } from "@constants"; import './timerHtml/timerHtml.scss'; import TimerHTMLComponent from './timerHtml/timerHtml'; -import scheduler from "../services/scheduler"; +import scheduler from "@services/scheduler"; import gameStateService from '@gameStateService'; import { unsubscribeAll } from '@common'; diff --git a/src/gamepuzzles/feedbackAudioHandler/feedbackAudioHandler.ts b/src/gamepuzzles/feedbackAudioHandler/feedbackAudioHandler.ts index 6f0f34782..2348d28b1 100644 --- a/src/gamepuzzles/feedbackAudioHandler/feedbackAudioHandler.ts +++ b/src/gamepuzzles/feedbackAudioHandler/feedbackAudioHandler.ts @@ -7,7 +7,7 @@ import { import { Utils } from '@common'; import gameStateService from '@gameStateService'; import { RiveMonsterComponent } from '@components/riveMonster/rive-monster-component'; -import scheduler from "../../services/scheduler"; +import scheduler from "@services/scheduler"; /** * Feedback type enum for different feedback scenarios diff --git a/src/gamepuzzles/puzzleHandler/puzzleHandler.ts b/src/gamepuzzles/puzzleHandler/puzzleHandler.ts index b6685c723..891105690 100644 --- a/src/gamepuzzles/puzzleHandler/puzzleHandler.ts +++ b/src/gamepuzzles/puzzleHandler/puzzleHandler.ts @@ -3,7 +3,7 @@ import WordPuzzleLogic from '../wordPuzzleLogic/wordPuzzleLogic'; import { FeedbackTextEffects } from '@components/feedback-text'; import { FeedbackAudioHandler, FeedbackType } from '@gamepuzzles'; import gameStateService from '@gameStateService'; -import scheduler from "../../services/scheduler"; +import scheduler from "@services/scheduler"; /** diff --git a/src/scenes/gameplay-scene/gameplay-flow-manager.ts b/src/scenes/gameplay-scene/gameplay-flow-manager.ts index 58ea1c046..a01359a8d 100644 --- a/src/scenes/gameplay-scene/gameplay-flow-manager.ts +++ b/src/scenes/gameplay-scene/gameplay-flow-manager.ts @@ -22,7 +22,7 @@ import PuzzleHandler from "@gamepuzzles/puzzleHandler/puzzleHandler"; import { StoneHandler, AudioPlayer } from "@components"; import { MiniGameHandler } from '@miniGames/miniGameHandler'; import TutorialHandler from '@tutorials'; -import scheduler from "../../services/scheduler"; +import scheduler from "@services/scheduler"; export class GameplayFlowManager { diff --git a/src/scenes/gameplay-scene/gameplay-scene.ts b/src/scenes/gameplay-scene/gameplay-scene.ts index 39bca89a3..9ae158470 100644 --- a/src/scenes/gameplay-scene/gameplay-scene.ts +++ b/src/scenes/gameplay-scene/gameplay-scene.ts @@ -35,7 +35,7 @@ import { GameplayInputManager } from './gameplay-input-manager'; import { MonsterController } from './monster-controller'; import { GameplayUIManager } from "./gameplay-ui-manager"; import { GameplayFlowManager } from "./gameplay-flow-manager"; -import scheduler from "../../services/scheduler"; +import scheduler from "@services/scheduler"; export class GameplayScene { // #region Properties diff --git a/src/scenes/gameplay-scene/monster-controller.ts b/src/scenes/gameplay-scene/monster-controller.ts index b8d74928e..8516b6cac 100644 --- a/src/scenes/gameplay-scene/monster-controller.ts +++ b/src/scenes/gameplay-scene/monster-controller.ts @@ -3,7 +3,7 @@ import { RiveMonsterComponent } from '@components/riveMonster/rive-monster-compo import gameSettingsService from '@gameSettingsService'; import gameStateService from '@gameStateService'; import { GameplayInputManager } from "./gameplay-input-manager"; -import scheduler from "../../services/scheduler"; +import scheduler from "@services/scheduler"; export class MonsterController { // #region Properties diff --git a/src/tutorials/index.ts b/src/tutorials/index.ts index 28c382462..e74a6f07e 100644 --- a/src/tutorials/index.ts +++ b/src/tutorials/index.ts @@ -4,7 +4,7 @@ import WordPuzzleTutorial from './WordPuzzleTutorial/WordPuzzleTutorial'; import AudioPuzzleTutorial from './AudioPuzzleTutorial/AudioPuzzleTutorial'; import gameStateService from '@gameStateService'; import { getGameTypeName, isGameTypeAudio } from '@common'; -import scheduler, { TimerId } from '../services/scheduler'; +import scheduler, { TimerId } from '@services/scheduler'; type TutorialInitParams = { context: CanvasRenderingContext2D; diff --git a/tsconfig.json b/tsconfig.json index 03bbc7066..186100bc6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -68,8 +68,8 @@ "@gameStateService": ["src/gameStateService"], "@gameSettingsService/*": ["src/gameSettingsService/*"], "@gameSettingsService": ["src/gameSettingsService"], - "@tutorials/*": ["src/tutorials/*"], - "@tutorials":["src/tutorials"], + "@tutorials": ["src/tutorials"], + "@services/*": ["src/services/*"], "@miniGameStateService": ["src/miniGame/miniGameStateService"], "@miniGameStateService/*": ["src/miniGame/miniGameStateService/*"], "@miniGames": ["src/miniGame/miniGames"], diff --git a/webpack.config.js b/webpack.config.js index 1c361651d..035dec4dd 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -70,6 +70,7 @@ var config = { '@gameStateService': path.resolve(__dirname, 'src/gameStateService/'), '@gameSettingsService': path.resolve(__dirname, 'src/gameSettingsService/'), '@tutorials': path.resolve(__dirname, 'src/tutorials/'), + '@services': path.resolve(__dirname, 'src/services/'), '@miniGameStateService': path.resolve(__dirname, 'src/miniGame/miniGameStateService'), '@miniGames': path.resolve(__dirname, 'src/miniGame/miniGames'), }, From 61fa02d8301b32b2de90681277366ee779fb5d4e Mon Sep 17 00:00:00 2001 From: Jan Francis Berdan Date: Thu, 22 Jan 2026 16:46:59 +0800 Subject: [PATCH 12/20] chore: removed unused code --- src/scenes/gameplay-scene/gameplay-scene.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/scenes/gameplay-scene/gameplay-scene.ts b/src/scenes/gameplay-scene/gameplay-scene.ts index 9ae158470..c35382644 100644 --- a/src/scenes/gameplay-scene/gameplay-scene.ts +++ b/src/scenes/gameplay-scene/gameplay-scene.ts @@ -435,7 +435,6 @@ export class GameplayScene { } public handleVisibilityChange(): void { - // this.audioPlayer.stopAllAudios(); gameStateService.publish(gameStateService.EVENTS.GAME_PAUSE_STATUS_EVENT, true); this.pauseGamePlay(); } From 26f7c5652897d1e4a87d7a3a91903f052f980b02 Mon Sep 17 00:00:00 2001 From: Jan Francis Berdan Date: Thu, 22 Jan 2026 17:05:34 +0800 Subject: [PATCH 13/20] chore: added comments in audio-player regarding the new functionalities --- src/components/audio-player.ts | 88 ++++++++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 5 deletions(-) diff --git a/src/components/audio-player.ts b/src/components/audio-player.ts index ed6678bfa..567692c0c 100644 --- a/src/components/audio-player.ts +++ b/src/components/audio-player.ts @@ -3,6 +3,11 @@ import { AUDIO_PATH_BTN_CLICK } from "@constants"; import scheduler from "@services/scheduler"; +/** + * Singleton class that manages audio playback for the game. + * Handles both pausible game audio and non-pausable UI audio, + * along with preloading, queuing, and audio context management. + */ export class AudioPlayer { public static instance: AudioPlayer; @@ -33,6 +38,14 @@ export class AudioPlayer { } + /** + * Plays a UI audio effect (non-pausable). + * Used for interface sounds like button clicks that should persist even when the game is paused. + * @param audioSrc - The source path or identifier of the audio to play. + * @param volume - The volume level (default: 1). + * @param onEnded - Optional callback to execute when audio playback ends. + * @returns The created AudioBufferSourceNode if successful, or null. + */ playUIAudio(audioSrc: string, volume: number = 1, onEnded?: () => void) { const audioBuffer: AudioBuffer = AudioPlayer.audioBuffers.get(audioSrc); if (audioBuffer) { @@ -56,11 +69,19 @@ export class AudioPlayer { return null; } + /** + * Plays the standard button click sound. + */ async playButtonClickSound() { const audioSrc: string = AUDIO_PATH_BTN_CLICK; this.playUIAudio(audioSrc); } + /** + * Internal helper to load audio data from a URL and decode it into an AudioBuffer. + * @param audioSrc - The URL of the audio file. + * @returns A promise resolving to the decoded AudioBuffer. + */ private async loadAndDecodeAudio(audioSrc: string): Promise { return new Promise(async (resolve, reject) => { try { @@ -77,6 +98,10 @@ export class AudioPlayer { }); } + /** + * Preloads and decodes the prompt audio file. + * @param audioSrc - The source path of the audio to preload. + */ async preloadPromptAudio(audioSrc: string) { const audioBuffer: AudioBuffer = await this.loadAndDecodeAudio(audioSrc); if (audioBuffer) { @@ -84,6 +109,10 @@ export class AudioPlayer { } } + /** + * Preloads and decodes a generic game audio file if not already cached. + * @param audioSrc - The source path of the audio to preload. + */ async preloadGameAudio(audioSrc: string) { if (AudioPlayer.audioBuffers.has(audioSrc)) { return; @@ -95,6 +124,13 @@ export class AudioPlayer { } } + /** + * Plays a game audio effect (pausable). + * @param audioSrc - The source path or identifier of the audio to play. + * @param volume - The volume level (default: 1). + * @param onEnded - Optional callback to execute when audio playback ends. + * @returns The created AudioBufferSourceNode if successful, or null. + */ playAudio(audioSrc: string, volume: number = 1, onEnded?: () => void) { const audioBuffer: AudioBuffer = AudioPlayer.audioBuffers.get(audioSrc); if (audioBuffer) { @@ -120,9 +156,9 @@ export class AudioPlayer { } /** - * Stops the currently playing audio immediately (if any). - * Useful when you want to cut off playback before it completes. - */ + * Stops the currently playing audio immediately (if any). + * Useful when you want to cut off playback before it completes. + */ public stopAudio = (): void => { // Stop single active sourceNode if it's playing if (this.sourceNode) { @@ -159,7 +195,11 @@ export class AudioPlayer { } }; - + /** + * Queues multiple audio files to be played sequentially. + * @param loop - Whether to loop the sequence. + * @param fileUrl - Variable number of audio file URLs to play. + */ playAudioQueue = (loop: boolean = false, ...fileUrl: string[]): void => { if (fileUrl.length > 0) { this.audioQueue = fileUrl; @@ -167,7 +207,10 @@ export class AudioPlayer { } }; - + /** + * Plays the currently preloaded prompt audio. + * @param onEndedCallback - Optional callback to execute when playback finishes. + */ playPromptAudio = (onEndedCallback: () => void | null = null) => { if (this.promptAudioBuffer) { const sourceNode = this.audioContext.createBufferSource(); @@ -231,6 +274,9 @@ export class AudioPlayer { } } + /** + * Stops and clears the currently playing feedback audio sequence. + */ stopFeedbackAudio = (): void => { if (this.sourceNode) { this.sourceNode.stop(); @@ -239,6 +285,9 @@ export class AudioPlayer { this.audioQueue = []; }; + /** + * Stops all currently playing audio sources, including feedback and queued sounds. + */ stopAllAudios = () => { if (this.sourceNode) { this.sourceNode.stop(); @@ -252,18 +301,29 @@ export class AudioPlayer { this.audioSourcs = []; }; + /** + * Suspends the audio context, effectively pausing all game audio. + */ pauseAllAudios = () => { if( this.audioContext && this.audioContext.state === "running") { this.audioContext.suspend(); } } + /** + * Resumes the audio context, unpausing all game audio. + */ resumeAllAudios = () => { if( this.audioContext && this.audioContext.state === "suspended") { this.audioContext.resume(); } } + /** + * Internal helper to fetch and play an audio file from the queue. + * @param index - The index in the queue to play. + * @param loop - Whether the sequence should loop. + */ private playFetch = (index: number, loop: boolean) => { if (index >= this.audioQueue.length) { this.stopFeedbackAudio(); @@ -286,6 +346,11 @@ export class AudioPlayer { } }; + /** + * Internal handler for when an audio source in a sequence finishes playing. + * @param index - The index of the audio that just finished. + * @param loop - Whether the sequence should loop. + */ private handleAudioEnded = (index: number, loop: boolean): void => { if (this.sourceNode) { this.sourceNode.onended = null; @@ -297,9 +362,18 @@ export class AudioPlayer { }; } +/** + * Manages the initialization and retrieval of Web Audio API AudioContexts. + * Provides separate contexts for pausable game audio and non-pausable UI audio. + */ class AudioContextManager { private static instance: AudioContext | null = null; private static nonPausableAudioContext: AudioContext | null = null; + + /** + * Returns the shared AudioContext for game-related audio. + * @returns The active AudioContext. + */ static getAudioContext(): AudioContext { if (!AudioContextManager.instance) { AudioContextManager.instance = new (window.AudioContext || @@ -308,6 +382,10 @@ class AudioContextManager { return AudioContextManager.instance; } + /** + * Returns the shared AudioContext for UI-related audio that should not be paused. + * @returns The active non-pausable AudioContext. + */ static getNonPausableAudioContext(): AudioContext { if (!AudioContextManager.nonPausableAudioContext) { AudioContextManager.nonPausableAudioContext = new (window.AudioContext || From 58eacac6fc034b09293707d0f0409b99a5dcc22a Mon Sep 17 00:00:00 2001 From: Jan Francis Berdan Date: Thu, 22 Jan 2026 17:19:45 +0800 Subject: [PATCH 14/20] chore: fixed unit testing regarding changes on how to import scheduler --- jest.config.js | 1 + src/components/timerHtml/timerHtml.spec.ts | 2 +- src/scenes/gameplay-scene/gameplay-scene.spec.ts | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/jest.config.js b/jest.config.js index 65e66ef6c..b2c9bb0d4 100644 --- a/jest.config.js +++ b/jest.config.js @@ -26,6 +26,7 @@ module.exports = { "^@gamepuzzles(.*)$": "/src/gamepuzzles$1", "@gameSettingsService/*": ["/src/gameSettingsService/$1"], "@tutorials/*": ["/src/tutorials/$1"], + "@services/(.*)": "/src/services/$1", "^@miniGames(.*)$": ["/src/miniGame/miniGames/$1"], "^@miniGameStateService(.*)$": ["/src/miniGame/miniGameStateService/$1"], "@curiouslearning/analytics": "/node_modules/@curiouslearning/analytics/dist/index.js" diff --git a/src/components/timerHtml/timerHtml.spec.ts b/src/components/timerHtml/timerHtml.spec.ts index 1f7fce065..923a8fc1c 100644 --- a/src/components/timerHtml/timerHtml.spec.ts +++ b/src/components/timerHtml/timerHtml.spec.ts @@ -3,7 +3,7 @@ import { AUDIO_TIMEOUT } from '@constants'; import TimerTicking from '../timer-ticking'; import TimerHTMLComponent from './timerHtml'; import { AudioPlayer } from '@components'; -import scheduler from '../../services/scheduler'; +import scheduler from '@services/scheduler'; // Mock dependencies jest.mock('./timerHtml', () => { diff --git a/src/scenes/gameplay-scene/gameplay-scene.spec.ts b/src/scenes/gameplay-scene/gameplay-scene.spec.ts index 28dfc11ce..c1c5b9b6b 100644 --- a/src/scenes/gameplay-scene/gameplay-scene.spec.ts +++ b/src/scenes/gameplay-scene/gameplay-scene.spec.ts @@ -33,7 +33,7 @@ import gameSettingsService from '@gameSettingsService'; import miniGameStateService from '@miniGameStateService' import { SCENE_NAME_GAME_PLAY } from "@constants"; import { AnalyticsIntegration } from '../../analytics/analytics-integration'; -import scheduler from '../../services/scheduler'; +import scheduler from '@services/scheduler'; // --- IMPORTANT: All mocks must be defined BEFORE imports to ensure proper isolation --- // Mock Rive (prevents any real Rive/WebGL code from running in Jest) From fb2358bf028ef26732806822de7366e975130071 Mon Sep 17 00:00:00 2001 From: Jan Francis Berdan Date: Wed, 28 Jan 2026 19:55:41 +0800 Subject: [PATCH 15/20] fix: fix for audio are missing when switching scenes added a destroy on cleanup scene --- src/sceneHandler/scene-handler.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/sceneHandler/scene-handler.ts b/src/sceneHandler/scene-handler.ts index e16cef284..40f7b4d7d 100644 --- a/src/sceneHandler/scene-handler.ts +++ b/src/sceneHandler/scene-handler.ts @@ -22,6 +22,8 @@ import gameStateService from '@gameStateService'; import gameSettingsService from '@gameSettingsService'; import { FeatureFlagsService} from '@curiouslearning/features'; import { FEATURE_QUICK_START } from '../services/features/constants'; +import scheduler from "@services/scheduler"; +import { AudioPlayer } from '@components'; const featureFlagService = new FeatureFlagsService({ metaData: { userId: pseudoId } @@ -136,6 +138,8 @@ export class SceneHandler { private cleanupScene() { this.activeScene['scene'] && this.activeScene['scene']?.dispose(); + AudioPlayer.instance.resumeAllAudios(); + scheduler.destroy(); } private gotoScene(sceneName: string) { From 9638236094586c725e045b5d3d7d2c3eb082844f Mon Sep 17 00:00:00 2001 From: Jan Francis Berdan Date: Wed, 28 Jan 2026 20:33:47 +0800 Subject: [PATCH 16/20] fix: fix on non tutorial levels the timeRef is incorrect --- src/scenes/gameplay-scene/gameplay-scene.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scenes/gameplay-scene/gameplay-scene.ts b/src/scenes/gameplay-scene/gameplay-scene.ts index c35382644..d78ccd697 100644 --- a/src/scenes/gameplay-scene/gameplay-scene.ts +++ b/src/scenes/gameplay-scene/gameplay-scene.ts @@ -283,7 +283,6 @@ export class GameplayScene { // #region Game Loop draw(deltaTime: number) { - const timeRef = { value: this.time }; if (!this.isPaused) { scheduler.update(deltaTime); } @@ -291,6 +290,7 @@ export class GameplayScene { { deltaTime = 0; } + const timeRef = { value: this.time }; this.tutorial?.handleTutorialAndGameStart({ deltaTime, isGameStarted: this.isGameStarted, From ddebbdcbe9e06d421005d7ecf38af76d50d82508 Mon Sep 17 00:00:00 2001 From: Jan Francis Berdan Date: Thu, 29 Jan 2026 13:58:35 +0800 Subject: [PATCH 17/20] fix: added pause event for the prompt text --- src/components/prompt-text/prompt-text.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/components/prompt-text/prompt-text.ts b/src/components/prompt-text/prompt-text.ts index d1a662d11..70227e0a6 100644 --- a/src/components/prompt-text/prompt-text.ts +++ b/src/components/prompt-text/prompt-text.ts @@ -145,6 +145,7 @@ export class PromptText extends BaseHTML { GAME_HAS_STARTED, STONEDROP, LOADPUZZLE, + GAME_PAUSE_STATUS_EVENT } = gameStateService.EVENTS; // Subscribe to submitted letters count updates. @@ -197,6 +198,16 @@ export class PromptText extends BaseHTML { } ) ); + + //Subscription for game starting. + this.eventListeners.push( + gameStateService.subscribe( + GAME_PAUSE_STATUS_EVENT, + (isPaused: boolean) => { + this.handleGamePause(isPaused); + } + ) + ); } private setPromptInitialAudioDelayValues(isTutorialOn: boolean = false) { From 484d51004ecebba9a21e2cce8f2b7f0367917c1c Mon Sep 17 00:00:00 2001 From: Jan Francis Berdan Date: Thu, 29 Jan 2026 14:01:43 +0800 Subject: [PATCH 18/20] chore: edited some comments --- src/components/audio-player.ts | 2 +- .../miniGames/treasureChest/treasureChestMiniGame.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/audio-player.ts b/src/components/audio-player.ts index 567692c0c..b6817d120 100644 --- a/src/components/audio-player.ts +++ b/src/components/audio-player.ts @@ -48,7 +48,7 @@ export class AudioPlayer { */ playUIAudio(audioSrc: string, volume: number = 1, onEnded?: () => void) { const audioBuffer: AudioBuffer = AudioPlayer.audioBuffers.get(audioSrc); - if (audioBuffer) { + if (audioBuffer && this.nonPausableAudioContext) { const sourceNode = this.nonPausableAudioContext.createBufferSource(); const gainNode = this.nonPausableAudioContext.createGain(); sourceNode.buffer = audioBuffer; diff --git a/src/miniGame/miniGames/treasureChest/treasureChestMiniGame.spec.ts b/src/miniGame/miniGames/treasureChest/treasureChestMiniGame.spec.ts index 5e125030f..6443d13fd 100644 --- a/src/miniGame/miniGames/treasureChest/treasureChestMiniGame.spec.ts +++ b/src/miniGame/miniGames/treasureChest/treasureChestMiniGame.spec.ts @@ -37,7 +37,7 @@ describe('Testing TreasureChestMiniGame.', () => { // Simulate player collecting stones miniGame['collectedBeforeThreshold'] = 3; - // Run draw to trigger animation + // Start Minigame to trigger animation miniGame.start(); // After animation completes, processStoneCollection should run and call the callback From 58f40431efa2b0c50318e263ebf9aa1ea406b3b7 Mon Sep 17 00:00:00 2001 From: Jan Francis Berdan Date: Thu, 29 Jan 2026 14:02:50 +0800 Subject: [PATCH 19/20] chore: removed unused code --- src/miniGame/miniGames/treasureChest/treasureChest.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/miniGame/miniGames/treasureChest/treasureChest.ts b/src/miniGame/miniGames/treasureChest/treasureChest.ts index c78748ba8..e8bee1277 100644 --- a/src/miniGame/miniGames/treasureChest/treasureChest.ts +++ b/src/miniGame/miniGames/treasureChest/treasureChest.ts @@ -53,7 +53,6 @@ export default class TreasureChest { // If TreasureChestAnimation passes `stateTimer` as `time`, then `elapsed` is just `time`. - const elapsed = time; let scale = 1; let rotation = 0; From bf6e01d2667d92562a200938e600e6c2f1dc7912 Mon Sep 17 00:00:00 2001 From: Jan Francis Berdan Date: Thu, 29 Jan 2026 14:05:13 +0800 Subject: [PATCH 20/20] chore: added safe delay for setInterval in Scheduler --- src/services/scheduler.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/services/scheduler.ts b/src/services/scheduler.ts index 6c06cdb25..5f3c36fd0 100644 --- a/src/services/scheduler.ts +++ b/src/services/scheduler.ts @@ -57,11 +57,12 @@ class Scheduler { */ setInterval(callback: () => void, delay: number): TimerId { const id = nextTimerId++ as TimerId; + const safeDelay = Math.max(1, delay); // Minimum 1ms to prevent runaway intervals this.timers.set(id, { id, callback, - delay, - remaining: delay, + delay: safeDelay, + remaining: safeDelay, loop: true, }); return id;