diff --git a/src/CaptureHelpers.ts b/src/CaptureHelpers.ts index 9bcf1d986..8eb6b547f 100644 --- a/src/CaptureHelpers.ts +++ b/src/CaptureHelpers.ts @@ -28,7 +28,7 @@ export async function captureSceneToZip(viewer: Viewer, options: CaptureOptions) const fileEntries: ZipFileEntry[] = []; // This is some ugliness to take over the main code... in an ideal world we'd do this offscreen... - viewer.sceneTime = 0; + viewer.sceneTime.time = 0; viewer.rafTime = 0; for (let i = 0; i < options.frameCount; i++) { const t = i / (options.frameCount - 1); diff --git a/src/SaveManager.ts b/src/SaveManager.ts index d34a787bc..ac3ba3573 100644 --- a/src/SaveManager.ts +++ b/src/SaveManager.ts @@ -58,6 +58,19 @@ export class SaveManager { this.settingListeners[i].callback(this, key); } + public loadTime(key: string): number | null { + const timeStr = window.localStorage.getItem(`SceneTime_${key}`); + return timeStr ? parseInt(timeStr) : null; + } + + public saveTime(key: string, time: number) { + window.localStorage.setItem(`SceneTime_${key}`, Math.round(time).toString()); + } + + public deleteTime(key: string) { + window.localStorage.removeItem(`SceneTime_${key}`); + } + public addSettingListener(key: string, callback: SettingCallback, triggerNow: boolean = true): void { this.settingListeners.push({ callback, key }); if (triggerNow) diff --git a/src/SceneBase.ts b/src/SceneBase.ts index 2cfdd0ece..5f3c8d803 100644 --- a/src/SceneBase.ts +++ b/src/SceneBase.ts @@ -1,6 +1,6 @@ import { GfxDevice } from "./gfx/platform/GfxPlatform.js"; -import { SceneGfx, ViewerRenderInput } from "./viewer.js"; +import { SceneGfx, SceneTime, ViewerRenderInput } from "./viewer.js"; import { DataFetcher } from "./DataFetcher.js"; import { DataShare } from "./DataShare.js"; import { GfxRenderInstManager } from "./gfx/render/GfxRenderInstManager.js"; @@ -27,6 +27,7 @@ export interface SceneContext { destroyablePool: Destroyable[]; inputManager: InputManager; viewerInput: ViewerRenderInput; + sceneTime: SceneTime; } export interface SceneDesc { diff --git a/src/ZeldaWindWaker/Main.ts b/src/ZeldaWindWaker/Main.ts index beadef508..9226857bc 100644 --- a/src/ZeldaWindWaker/Main.ts +++ b/src/ZeldaWindWaker/Main.ts @@ -42,6 +42,7 @@ import { dStage_dt_c_roomLoader, dStage_dt_c_roomReLoader, dStage_dt_c_stageInit import { WoodPacket } from './d_wood.js'; import { fopAcM_create, fopAc_ac_c } from './f_op_actor.js'; import { cPhs__Status, fGlobals, fopDw_Draw, fopScn, fpcCt_Handler, fpcLy_SetCurrentLayer, fpcM_Management, fpcPf__Register, fpcSCtRq_Request, fpc_pc__ProfileList } from './framework.js'; +import { IS_DEVELOPMENT } from '../BuildVersion.js'; type SymbolData = { Filename: string, SymbolName: string, Data: ArrayBufferSlice }; type SymbolMapData = { SymbolData: SymbolData[] }; @@ -329,11 +330,12 @@ export class WindWakerRenderer implements Viewer.SceneGfx { public renderCache: GfxRenderCache; public time: number; // In milliseconds, affected by pause and time scaling + public isPlaying: boolean = false; public roomLayerMask: number = 0; public onstatechanged!: () => void; - constructor(public device: GfxDevice, public globals: dGlobals) { + constructor(public sceneId: string, public device: GfxDevice, public globals: dGlobals) { this.renderHelper = new GXRenderHelperGfx(device); this.renderCache = this.renderHelper.renderInstManager.gfxRenderCache; @@ -473,7 +475,6 @@ export class WindWakerRenderer implements Viewer.SceneGfx { // noclip modification: if we're paused, allow noclip camera control during demos const isPaused = viewerInput.deltaTime === 0; - // TODO: Determine the correct place for this // dCamera_c::Store() sets the camera params if the demo camera is active const demoCam = this.globals.scnPlay.demo.getSystem().getCamera(); if (demoCam && !isPaused) { @@ -501,6 +502,19 @@ export class WindWakerRenderer implements Viewer.SceneGfx { this.globals.context.inputManager.isMouseEnabled = true; } + // Save the camera state when play/pause is toggled + if( this.globals.context.sceneTime.isPlaying != this.isPlaying ) { + this.isPlaying = this.globals.context.sceneTime.isPlaying; + this.onstatechanged(); + + if(IS_DEVELOPMENT) { + // If pausing, save the time and pause state. If we reload, the demo will be paused at the same spot + // If resuming, clear the time and pause state. Reloading will restart the demo. + if (this.isPlaying) { this.globals.context.sceneTime.deleteState(this.sceneId); } + else { this.globals.context.sceneTime.saveState(this.sceneId); } + } + } + this.globals.camera = viewerInput.camera; // Not sure exactly where this is ordered... @@ -629,6 +643,7 @@ export class WindWakerRenderer implements Viewer.SceneGfx { this.extraTextures.destroy(device); this.globals.destroy(device); this.globals.frameworkGlobals.delete(this.globals); + if(IS_DEVELOPMENT) { this.globals.context.sceneTime.deleteState(this.sceneId); } } } @@ -891,7 +906,7 @@ class SceneDesc { this.globals = globals; globals.stageName = this.stageDir; - const renderer = new WindWakerRenderer(device, globals); + const renderer = new WindWakerRenderer(this.id, device, globals); context.destroyablePool.push(renderer); globals.renderer = renderer; @@ -977,9 +992,9 @@ class DemoDesc extends SceneDesc implements Viewer.SceneDesc { public layer: number, public offsetPos?:vec3, public rotY: number = 0, - public startCode?: number, - public eventFlags?: number, - public startFrame?: number, // noclip modification for easier debugging + public startCode: number = 0, + public eventFlags: number = 0, + public startFrame: number = 0, // noclip modification for easier debugging ) { super(stageDir, name, roomList); assert(this.roomList.length === 1); @@ -990,6 +1005,8 @@ class DemoDesc extends SceneDesc implements Viewer.SceneDesc { } public override async createScene(device: GfxDevice, context: SceneContext): Promise { + context.sceneTime.togglePlayPause(false); + const res = await super.createScene(device, context); this.playDemo(this.globals); return res; @@ -1027,6 +1044,11 @@ class DemoDesc extends SceneDesc implements Viewer.SceneDesc { // @TODO: Set noclip layer visiblity based on this.layer + if(IS_DEVELOPMENT) { + // Load the current scene time and play/pause state, if saved + globals.context.sceneTime.loadState(this.id); + } + // From dEvDtStaff_c::specialProcPackage() let demoData; if(globals.roomCtrl.demoArcName) @@ -1034,8 +1056,10 @@ class DemoDesc extends SceneDesc implements Viewer.SceneDesc { if (!demoData) demoData = globals.modelCache.resCtrl.getStageResByName(ResType.Stb, "Stage", this.stbFilename); - if( demoData ) { globals.scnPlay.demo.create(demoData, this.offsetPos, this.rotY / 180.0 * Math.PI, this.startFrame); } - else { console.warn('Failed to load demo data:', this.stbFilename); } + if( demoData ) { + globals.scnPlay.demo.setFrame(this.startFrame + globals.context.sceneTime.time / 1000.0 * 30.0); + globals.scnPlay.demo.create(demoData, this.offsetPos, this.rotY / 180.0 * Math.PI); + } else { console.warn('Failed to load demo data:', this.stbFilename); } return this.globals.renderer; } diff --git a/src/ZeldaWindWaker/d_demo.ts b/src/ZeldaWindWaker/d_demo.ts index db5738549..2a0098201 100644 --- a/src/ZeldaWindWaker/d_demo.ts +++ b/src/ZeldaWindWaker/d_demo.ts @@ -346,8 +346,8 @@ class dDemo_system_c implements TSystem { } export class dDemo_manager_c { - private frame: number; - private frameNoMsg: number; + private frame: number = 0; + private frameNoMsg: number = 0; private mode = EDemoMode.None; private curFile: ArrayBufferSlice | null; @@ -359,12 +359,12 @@ export class dDemo_manager_c { private globals: dGlobals ) { } - public getFrame() { return this.frame; } - public getFrameNoMsg() { return this.frameNoMsg; } + public getFrame() { return this.frameNoMsg; } + public setFrame(frame: number) { this.frame = frame; this.frameNoMsg = frame; } public getMode() { return this.mode; } public getSystem() { return this.system; } - public create(data: ArrayBufferSlice, originPos?: vec3, rotY?: number, startFrame?: number): boolean { + public create(data: ArrayBufferSlice, originPos?: vec3, rotY?: number): boolean { this.parser = new TParse(this.control); if (!this.parser.parse(data, 0)) { @@ -372,13 +372,11 @@ export class dDemo_manager_c { return false; } - this.control.forward(startFrame || 0); + this.control.forward(this.frame || 0); if (originPos) { this.control.transformSetOrigin(originPos, rotY || 0); } - this.frame = 0; - this.frameNoMsg = 0; this.curFile = data; this.mode = EDemoMode.Playing; @@ -397,7 +395,7 @@ export class dDemo_manager_c { return false; } - const dtFrames = this.globals.context.viewerInput.deltaTime / 1000.0 * 30; + let dtFrames = this.globals.context.viewerInput.deltaTime / 1000.0 * 30; // noclip modification: If a demo is suspended (waiting for the user to interact with a message), just resume if (this.control.isSuspended()) { this.control.setSuspend(0); } diff --git a/src/main.ts b/src/main.ts index c61ec006a..6736a6a7c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -431,9 +431,9 @@ class Main { if (inputManager.isKeyDownEventTriggered('Numpad3')) this._exportSaveData(); if (inputManager.isKeyDownEventTriggered('Period')) - this.ui.togglePlayPause(); + this.viewer.sceneTime.togglePlayPause(); if (inputManager.isKeyDown('Comma')) { - this.ui.togglePlayPause(false); + this.viewer.sceneTime.togglePlayPause(false); this.isFrameStep = true; } if (inputManager.isKeyDownEventTriggered('F9')) @@ -472,7 +472,7 @@ class Main { const shouldTakeScreenshot = this.viewer.inputManager.isKeyDownEventTriggered('Numpad7') || this.viewer.inputManager.isKeyDownEventTriggered('BracketRight'); let sceneTimeScale = this.sceneTimeScale; - if (!this.ui.isPlaying) { + if (!this.viewer.sceneTime.isPlaying) { if (this.isFrameStep) { sceneTimeScale /= 4.0; this.isFrameStep = false; @@ -482,7 +482,7 @@ class Main { } if (!this.viewer.externalControl) { - this.viewer.sceneTimeScale = sceneTimeScale; + this.viewer.sceneTime.timeScale = sceneTimeScale; this.viewer.update(updateInfo); } @@ -545,7 +545,7 @@ class Main { const byteLength = atob(this._saveStateTmp, 0, state); let byteOffs = 0; - this.viewer.sceneTime = this._saveStateView.getFloat32(byteOffs + 0x00, true); + this.viewer.sceneTime.time = this._saveStateView.getFloat32(byteOffs + 0x00, true); byteOffs += 0x04; byteOffs += deserializeCamera(this.viewer.camera, this._saveStateView, byteOffs); if (this.viewer.scene !== null && this.viewer.scene.deserializeSaveState) @@ -689,8 +689,6 @@ class Main { if (scene.createPanels) scenePanels = scene.createPanels(); this.ui.setScenePanels(scenePanels); - // Force time to play when loading a map. - this.ui.togglePlayPause(true); const sceneDescId = this._getCurrentSceneDescId()!; this.saveManager.setCurrentSceneDescId(sceneDescId); @@ -759,6 +757,9 @@ class Main { this.destroyablePool[i].destroy(device); this.destroyablePool.length = 0; + // Force time to play when loading a map (but allow createScene() to override this). + this.viewer.sceneTime.togglePlayPause(true); + // Unhide any hidden scene groups upon being loaded. if (sceneGroup.hidden) sceneGroup.hidden = false; @@ -778,8 +779,9 @@ class Main { const inputManager = this.viewer.inputManager; inputManager.reset(); const viewerInput = this.viewer.viewerRenderInput; + const sceneTime = this.viewer.sceneTime; const context: SceneContext = { - device, dataFetcher, dataShare, uiContainer, destroyablePool, inputManager, viewerInput, + device, dataFetcher, dataShare, uiContainer, destroyablePool, inputManager, viewerInput, sceneTime }; // The age delta on pruneOldObjects determines whether any resources will be shared at all. diff --git a/src/ui.ts b/src/ui.ts index 18e5fe5d6..28a3a3e49 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -2611,23 +2611,17 @@ class FullscreenButton extends SingleIconButton { } class PlayPauseButton extends SingleIconButton { - public onplaypause: ((shouldBePlaying: boolean) => void) | null = null; - public isPlaying: boolean; + public sceneTime: Viewer.SceneTime; public override syncStyle(): void { super.syncStyle(); - setFontelloIcon(this.icon, this.isPlaying ? FontelloIcon.pause : FontelloIcon.play); - this.tooltipElem.textContent = this.isPlaying ? 'Pause' : 'Play'; - } - - public setIsPlaying(isPlaying: boolean): void { - this.isPlaying = isPlaying; - this.syncStyle(); + setFontelloIcon(this.icon, this.sceneTime.isPlaying ? FontelloIcon.pause : FontelloIcon.play); + this.tooltipElem.textContent = this.sceneTime.isPlaying ? 'Pause' : 'Play'; } public onClick() { - if (this.onplaypause !== null) - this.onplaypause(!this.isPlaying); + this.sceneTime.togglePlayPause(); + this.syncStyle(); } } @@ -2692,8 +2686,6 @@ export class UI { private isDragging: boolean = false; private lastMouseActiveTime: number = -1; - public isPlaying: boolean = true; - public isEmbedMode: boolean = false; public isVisible: boolean = true; @@ -2769,10 +2761,7 @@ export class UI { this.studioPanel = new StudioPanel(this, viewer); this.toplevel.appendChild(this.studioPanel.elem); - this.playPauseButton.onplaypause = (shouldBePlaying) => { - this.togglePlayPause(shouldBePlaying); - }; - this.playPauseButton.setIsPlaying(this.isPlaying); + this.playPauseButton.sceneTime = viewer.sceneTime; this.about.onfaq = () => { this.faqPanel.elem.style.display = 'block'; @@ -2788,11 +2777,6 @@ export class UI { this.elem = this.toplevel; } - public togglePlayPause(shouldBePlaying: boolean = !this.isPlaying): void { - this.isPlaying = shouldBePlaying; - this.playPauseButton.setIsPlaying(this.isPlaying); - } - public toggleWebXRCheckbox(shouldBeChecked: boolean = !this.xrSettings.enableXRCheckBox.checked) { this.xrSettings.enableXRCheckBox.setChecked(shouldBeChecked); } @@ -2803,6 +2787,7 @@ export class UI { public update(): void { this.syncVisibilityState(); + this.playPauseButton.syncStyle(); } public setSaveState(saveState: string) { diff --git a/src/viewer.ts b/src/viewer.ts index ad96cb319..a430d9690 100644 --- a/src/viewer.ts +++ b/src/viewer.ts @@ -83,6 +83,28 @@ export function resizeCanvas(canvas: HTMLCanvasElement, width: number, height: n canvas.height = nh; } +export class SceneTime { + public time: number = 0; + public timeScale: number = 1.0; + public isPlaying: boolean = false; + + public togglePlayPause(shouldBePlaying = !this.isPlaying): void { this.isPlaying = shouldBePlaying; } + + public saveState(key: string) { + GlobalSaveManager.saveTime(key, this.isPlaying ? this.time : -this.time); + } + + public loadState(key: string) { + const res = GlobalSaveManager.loadTime(key); + this.time = Math.abs(res || 0); + this.isPlaying = (res === null || res >= 0) ? true : false; + } + + public deleteState(key: string) { + GlobalSaveManager.deleteTime(key); + } +} + export class Viewer { public inputManager: InputManager; public cameraController: CameraController | null = null; @@ -93,10 +115,9 @@ export class Viewer { static readonly FOV_Y_DEFAULT: number = MathConstants.TAU / 6; public fovY: number = Viewer.FOV_Y_DEFAULT; // Scene time. Can be paused / scaled / rewound / whatever. - public sceneTime: number = 0; + public sceneTime = new SceneTime(); // requestAnimationFrame time. Used to calculate dt from the new time. public rafTime: number = 0; - public sceneTimeScale: number = 1; public externalControl: boolean = false; public gfxDevice: GfxDevice; @@ -119,7 +140,7 @@ export class Viewer { this.gfxDevice = this.gfxSwapChain.getDevice(); this.viewerRenderInput = { camera: this.camera, - time: this.sceneTime, + time: this.sceneTime.time, deltaTime: 0, backbufferWidth: 0, backbufferHeight: 0, @@ -158,7 +179,7 @@ export class Viewer { private render(): void { this.viewerRenderInput.camera = this.camera; - this.viewerRenderInput.time = this.sceneTime; + this.viewerRenderInput.time = this.sceneTime.time; this.viewerRenderInput.backbufferWidth = this.canvas.width; this.viewerRenderInput.backbufferHeight = this.canvas.height; this.gfxSwapChain.configureSwapChain(this.canvas.width, this.canvas.height); @@ -205,7 +226,7 @@ export class Viewer { if (baseLayer === undefined) return; - this.viewerRenderInput.time = this.sceneTime; + this.viewerRenderInput.time = this.sceneTime.time; this.gfxSwapChain.configureSwapChain(baseLayer.framebufferWidth, baseLayer.framebufferHeight, baseLayer.framebuffer); const swapChainTex = this.gfxSwapChain.getOnscreenTexture(); @@ -303,14 +324,14 @@ export class Viewer { camera.setClipPlanes(5); if (this.cameraController) { - const result = this.cameraController.update(this.inputManager, dt, this.sceneTimeScale); + const result = this.cameraController.update(this.inputManager, dt, this.sceneTime.timeScale); if (result !== CameraUpdateResult.Unchanged) this.oncamerachanged(result === CameraUpdateResult.ImportantChange); } - const deltaTime = dt * this.sceneTimeScale; + const deltaTime = dt * this.sceneTime.timeScale; this.viewerRenderInput.deltaTime += deltaTime; - this.sceneTime += deltaTime; + this.sceneTime.time += deltaTime; if (updateInfo.webXRContext !== null && updateInfo.webXRContext.views && updateInfo.webXRContext.xrSession) { this.xrCameraController.update(updateInfo.webXRContext);