diff --git a/package.json b/package.json index 9d4dfa1..d0d1e03 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "replay-viewer", - "version": "0.3.3", + "version": "0.4.0", "description": "Rocket League replay viewer React component and tooling", "main": "./lib/index.js", "types": "./lib/index.d.ts", diff --git a/src/constants/eventNames.ts b/src/constants/eventNames.ts index 2836b0b..250251f 100644 --- a/src/constants/eventNames.ts +++ b/src/constants/eventNames.ts @@ -1,3 +1,5 @@ export const CAMERA_CHANGE = "CAMERA_CHANGE" export const CAMERA_FRAME_UPDATE = "CAMERA_FRAME_UPDATE" export const PLAY_PAUSE = "PLAY_PAUSE" +export const FRAME = "FRAME" +export const CANVAS_RESIZE = "CANVAS_RESIZE" diff --git a/src/eventbus/EventBus.ts b/src/eventbus/EventBus.ts index 89a4db9..b67031c 100644 --- a/src/eventbus/EventBus.ts +++ b/src/eventbus/EventBus.ts @@ -31,6 +31,9 @@ class EventBus { if (!this.listeners[type]) { this.listeners[type] = [] } + // Remove duplicate event listeners + this.removeEventListener(type, callback, scope) + // Add this listener to the list this.listeners[type].push({ type, callback, scope }) } diff --git a/src/eventbus/events/cameraChange.ts b/src/eventbus/events/cameraChange.ts index b461d02..b9d0274 100644 --- a/src/eventbus/events/cameraChange.ts +++ b/src/eventbus/events/cameraChange.ts @@ -3,6 +3,9 @@ import { Camera } from "three" import { CAMERA_CHANGE } from "../../constants/eventNames" import EventBus from "../EventBus" +/** + * Event fired when the active camera is updated with a new object. + */ export interface CameraChangeEvent { camera: Camera } diff --git a/src/eventbus/events/cameraFrameUpdate.ts b/src/eventbus/events/cameraFrameUpdate.ts index a07976b..396d552 100644 --- a/src/eventbus/events/cameraFrameUpdate.ts +++ b/src/eventbus/events/cameraFrameUpdate.ts @@ -3,6 +3,9 @@ import { Vector3 } from "three" import { CAMERA_FRAME_UPDATE } from "../../constants/eventNames" import EventBus from "../EventBus" +/** + * Event that fires telling all cameras to adjust their settings. + */ export interface CameraFrameUpdateEvent { ballPosition: Vector3 ballCam: boolean diff --git a/src/eventbus/events/canvasResize.ts b/src/eventbus/events/canvasResize.ts new file mode 100644 index 0000000..6a29506 --- /dev/null +++ b/src/eventbus/events/canvasResize.ts @@ -0,0 +1,16 @@ +import { CANVAS_RESIZE } from "../../constants/eventNames" +import EventBus from "../EventBus" + +/** + * Fires when the main canvas is resized and its dimensions are adjusted. + */ +export interface CanvasResizeEvent { + width: number + height: number +} + +export const { + addEventListener: addCanvasResizeListener, + removeEventListener: removeCanvasResizeListener, + dispatch: dispatchCanvasResizeEvent, +} = EventBus.buildEvent(CANVAS_RESIZE) diff --git a/src/eventbus/events/frame.ts b/src/eventbus/events/frame.ts new file mode 100644 index 0000000..1480df7 --- /dev/null +++ b/src/eventbus/events/frame.ts @@ -0,0 +1,15 @@ +import { FRAME } from "../../constants/eventNames" +import EventBus from "../EventBus" + +/** + * Fires each time the global game clock advances a frame or updates its current frame. + */ +export interface FrameEvent { + frame: number +} + +export const { + addEventListener: addFrameListener, + removeEventListener: removeFrameListener, + dispatch: dispatchFrameEvent, +} = EventBus.buildEvent(FRAME) diff --git a/src/eventbus/events/playPause.ts b/src/eventbus/events/playPause.ts new file mode 100644 index 0000000..7bcdebc --- /dev/null +++ b/src/eventbus/events/playPause.ts @@ -0,0 +1,15 @@ +import { PLAY_PAUSE } from "../../constants/eventNames" +import EventBus from "../EventBus" + +/** + * Fires when the global game clock has paused. + */ +export interface PlayPauseEvent { + paused: boolean +} + +export const { + addEventListener: addPlayPauseListener, + removeEventListener: removePlayPauseListener, + dispatch: dispatchPlayPauseEvent, +} = EventBus.buildEvent(PLAY_PAUSE) diff --git a/src/managers/CameraManager.ts b/src/managers/CameraManager.ts index 9e14520..dc2c1db 100644 --- a/src/managers/CameraManager.ts +++ b/src/managers/CameraManager.ts @@ -8,6 +8,12 @@ import { } from "../constants/gameObjectNames" import { dispatchCameraChange } from "../eventbus/events/cameraChange" import { dispatchCameraFrameUpdate } from "../eventbus/events/cameraFrameUpdate" +import { + addCanvasResizeListener, + CanvasResizeEvent, + removeCanvasResizeListener, +} from "../eventbus/events/canvasResize" +import { addFrameListener, removeFrameListener } from "../eventbus/events/frame" import SceneManager from "./SceneManager" const ORTHOGRAPHIC_CAMERA_NAMES: string[] = Object.keys(ORTHOGRAPHIC).map( @@ -33,15 +39,18 @@ class CameraManager { this.activeCamera.position.z = 5000 this.activeCamera.position.y = 750 + + addFrameListener(this.update) + addCanvasResizeListener(this.updateSize) } - updateSize(width: number, height: number) { + private readonly updateSize = ({ width, height }: CanvasResizeEvent) => { this.width = width this.height = height this.updateCameraSize() } - update() { + private readonly update = () => { const { position } = SceneManager.getInstance().ball.ball dispatchCameraFrameUpdate({ ballPosition: position, @@ -105,6 +114,25 @@ class CameraManager { camera.aspect = width / height camera.updateProjectionMatrix() } else if (camera instanceof OrthographicCamera) { + /** + * Here, we are computing the zoom of the camera given the aspect ratio. For cameras with an + * aspect ratio greater than 4:3, we base the zoom on the height. Otherwise, we use width. The + * minimum zoom should always be 0.02. + * + * The zoom when based on the height is a simple linear function y = x / 12500 + 0.01, where x + * is the new height and y is the desired zoom. + * + * The denominator of the width-based computation is simply the slope of the previous + * function, 12500, multiplied by 1.3 since this is aspect ratio breaking point we have set in + * the if statement. + */ + if (width / height > 1.3) { + const newZoom = height / 12500 + 0.01 + camera.zoom = Math.max(newZoom, 0.02) + } else { + const newZoom = width / 16250 + 0.01 + camera.zoom = Math.max(newZoom, 0.02) + } camera.left = -width / 2 camera.right = width / 2 camera.top = height / 2 @@ -116,6 +144,7 @@ class CameraManager { private setActiveCamera(camera: Camera) { this.activeCamera = camera this.updateCameraSize() + this.update() } /** @@ -134,6 +163,14 @@ class CameraManager { CameraManager.instance = new CameraManager() return CameraManager.instance } + static destruct() { + const { instance } = CameraManager + if (instance) { + removeFrameListener(instance.update) + removeCanvasResizeListener(instance.updateSize) + CameraManager.instance = undefined + } + } } export interface CameraLocationOptions { diff --git a/src/managers/GameManager.ts b/src/managers/GameManager.ts index ad6217d..5dd508c 100644 --- a/src/managers/GameManager.ts +++ b/src/managers/GameManager.ts @@ -2,7 +2,26 @@ import { WebGLRenderer } from "three" import defaultGameBuilder from "../builders/GameBuilder" import EventBus from "../eventbus/EventBus" -import FPSClock, { FPSClockSubscriberOptions } from "../utils/FPSClock" +import { + addCameraChangeListener, + removeCameraChangeListener, +} from "../eventbus/events/cameraChange" +import { + addCanvasResizeListener, + CanvasResizeEvent, + removeCanvasResizeListener, +} from "../eventbus/events/canvasResize" +import { + addFrameListener, + FrameEvent, + removeFrameListener, +} from "../eventbus/events/frame" +import { + addPlayPauseListener, + PlayPauseEvent, + removePlayPauseListener, +} from "../eventbus/events/playPause" +import FPSClock from "../utils/FPSClock" import AnimationManager from "./AnimationManager" import CameraManager from "./CameraManager" import SceneManager from "./SceneManager" @@ -21,18 +40,22 @@ export class GameManager { this.animate = this.animate.bind(this) this.render = this.render.bind(this) this.clock = clock - clock.subscribe(this.animate) AnimationManager.getInstance().playAnimationClips() + addPlayPauseListener(this.onPlayPause) + addFrameListener(this.animate) + addCanvasResizeListener(this.updateSize) + addCameraChangeListener(this.render) } - animate({ }: FPSClockSubscriberOptions) { + onPlayPause = ({ paused }: PlayPauseEvent) => { + paused ? this.clock.pause() : this.clock.play() + } + + animate({ }: FrameEvent) { const delta = this.clock.getDelta() - CameraManager.getInstance().update() if (delta) { - SceneManager.getInstance().update() - CameraManager.getInstance().update() AnimationManager.getInstance().updateAnimationClips(delta) this.render() } @@ -42,24 +65,18 @@ export class GameManager { return this.renderer.domElement } - updateSize(width: number = 640, height: number = 480) { - CameraManager.getInstance().updateSize(width, height) - this.renderer.setSize(width, height) - this.render() - } - - render() { + private readonly render = () => { const { scene } = SceneManager.getInstance() const { activeCamera } = CameraManager.getInstance() this.renderer.render(scene, activeCamera) } - static builder = defaultGameBuilder - - private destruct() { - this.clock.unsubscribe(this.animate) - this.clock.reset() - EventBus.reset() + private readonly updateSize = ({ + width = 640, + height = 480, + }: CanvasResizeEvent) => { + this.renderer.setSize(width, height) + this.render() } /** @@ -67,6 +84,7 @@ export class GameManager { * Managers are singletons * ======================================== */ + static builder = defaultGameBuilder private static instance?: GameManager static getInstance() { if (!GameManager.instance) { @@ -75,10 +93,24 @@ export class GameManager { return GameManager.instance } static init(options: GameManagerOptions) { - if (GameManager.instance) { - GameManager.instance.destruct() - } GameManager.instance = new GameManager(options) return GameManager.instance } + static destruct() { + // Destruct other managers + SceneManager.destruct() + CameraManager.destruct() + + // Handle destruction of the existing game + const { instance } = GameManager + if (instance) { + removePlayPauseListener(instance.onPlayPause) + removeFrameListener(instance.animate) + removeCanvasResizeListener(instance.updateSize) + removeCameraChangeListener(instance.render) + instance.clock.reset() + EventBus.reset() + GameManager.instance = undefined + } + } } diff --git a/src/managers/SceneManager.ts b/src/managers/SceneManager.ts index 80fba56..09f36ba 100644 --- a/src/managers/SceneManager.ts +++ b/src/managers/SceneManager.ts @@ -1,5 +1,6 @@ import { Scene } from "three" +import { addFrameListener, removeFrameListener } from "../eventbus/events/frame" import BallManager from "./models/BallManager" import FieldManager from "./models/FieldManager" import PlayerManager from "./models/PlayerManager" @@ -22,9 +23,11 @@ export default class SceneManager { this.ball = ball this.field = field this.players = players + + addFrameListener(this.update) } - update() { + private readonly update = () => { for (const player of this.players) { if (player.carGroup.position.y < 0) { player.carGroup.visible = false @@ -50,4 +53,11 @@ export default class SceneManager { SceneManager.instance = new SceneManager(options) return SceneManager.instance } + static destruct() { + const { instance } = SceneManager + if (instance) { + removeFrameListener(instance.update) + SceneManager.instance = undefined + } + } } diff --git a/src/utils/FPSClock.ts b/src/utils/FPSClock.ts index 0de4e53..b89953f 100644 --- a/src/utils/FPSClock.ts +++ b/src/utils/FPSClock.ts @@ -1,12 +1,6 @@ +import { dispatchFrameEvent } from "../eventbus/events/frame" import { ReplayData } from "../models/ReplayData" -export interface FPSClockSubscriberOptions { - frame: number - paused: boolean -} - -type FPSClockSubscriber = (options: FPSClockSubscriberOptions) => void - /** * This clock provides a simple callback system that keeps track of elapsed and delta time * transformations. This makes it extremely easy to parse the deltas of a replay by their frame @@ -39,12 +33,10 @@ export default class FPSClock { private paused: boolean private animation?: number - private callbacks: FPSClockSubscriber[] constructor(frameToDuration: number[]) { this.frameToDuration = frameToDuration this.paused = true - this.callbacks = [] this.deltaQueue = [] this.elapsedTime = 0 this.currentFrame = 0 @@ -54,17 +46,8 @@ export default class FPSClock { this.timeout() } - public subscribe(callback: FPSClockSubscriber) { - this.callbacks.push(callback) - } - - public unsubscribe(callback: FPSClockSubscriber) { - this.callbacks = this.callbacks.filter(value => value !== callback) - } - public reset() { this.setFrame(0) - this.callbacks = [] } public setFrame(frame: number) { @@ -147,12 +130,6 @@ export default class FPSClock { } } - private doCallbacks() { - for (const callback of this.callbacks) { - callback({ frame: this.currentFrame, paused: this.paused }) - } - } - private timeout(enable: boolean = true) { if (enable) { this.animation = setInterval(this.update, 1000 / 60) as any @@ -161,6 +138,10 @@ export default class FPSClock { } } + private doCallbacks() { + dispatchFrameEvent({ frame: this.currentFrame }) + } + /** * Note that the final frame is ignored when considering the elapsed time per frame. If we * considered this final delta, we would need a frame to "animate to". diff --git a/src/viewer/components/GameManagerLoader.tsx b/src/viewer/components/GameManagerLoader.tsx index 89ad333..78111f1 100644 --- a/src/viewer/components/GameManagerLoader.tsx +++ b/src/viewer/components/GameManagerLoader.tsx @@ -41,6 +41,10 @@ class GameManagerLoader extends Component { }) } + componentWillUnmount() { + GameManager.destruct() + } + handleProgress = (item: any, loaded: number, total: number) => { const newPercent = Math.round((loaded / total) * 1000) / 10 const { percentLoaded } = this.state diff --git a/src/viewer/components/PlayControls.tsx b/src/viewer/components/PlayControls.tsx index 917f3bc..c7a7bcf 100644 --- a/src/viewer/components/PlayControls.tsx +++ b/src/viewer/components/PlayControls.tsx @@ -1,8 +1,14 @@ import Button from "@material-ui/core/Button" import Grid from "@material-ui/core/Grid" import React, { Component } from "react" + +import { + addPlayPauseListener, + dispatchPlayPauseEvent, + PlayPauseEvent, + removePlayPauseListener, +} from "../../eventbus/events/playPause" import { GameManager } from "../../managers/GameManager" -import { FPSClockSubscriberOptions } from "../../utils/FPSClock" interface Props {} @@ -17,32 +23,34 @@ export default class PlayControls extends Component { paused: false, } - this.onClockUpdate = this.onClockUpdate.bind(this) - GameManager.getInstance().clock.subscribe(this.onClockUpdate) + addPlayPauseListener(this.onPlayPause) } componentWillUnmount() { - GameManager.getInstance().clock.unsubscribe(this.onClockUpdate) + removePlayPauseListener(this.onPlayPause) } - onClockUpdate({ paused }: FPSClockSubscriberOptions) { - if (paused !== this.state.paused) { - this.setState({ - paused, - }) - } + setPlayPause = () => { + const isPaused = this.state.paused + dispatchPlayPauseEvent({ + paused: !isPaused, + }) + } + + onPlayPause = ({ paused }: PlayPauseEvent) => { + this.setState({ + paused, + }) } render() { const { clock } = GameManager.getInstance() - const onPlayPauseClick = () => - clock.isPaused() ? clock.play() : clock.pause() const onResetClick = () => clock.setFrame(0) return ( - diff --git a/src/viewer/components/ReplayViewer.tsx b/src/viewer/components/ReplayViewer.tsx index f53edab..83cad97 100644 --- a/src/viewer/components/ReplayViewer.tsx +++ b/src/viewer/components/ReplayViewer.tsx @@ -6,6 +6,7 @@ import { styled } from "@material-ui/styles" import React, { createRef, PureComponent, RefObject } from "react" import FullScreen from "react-full-screen" +import { dispatchCanvasResizeEvent } from "../../eventbus/events/canvasResize" import { GameManager } from "../../managers/GameManager" import Scoreboard from "./ScoreBoard" @@ -33,11 +34,9 @@ class ReplayViewer extends PureComponent { if (!current) { throw new Error("Did not mount replay viewer correctly") } - const { clientWidth: width, clientHeight: height } = current const { gameManager } = this.props current.appendChild(gameManager.getDOMNode()) - gameManager.updateSize(width, height) - gameManager.render() + this.handleResize() gameManager.clock.play() addEventListener("resize", this.handleResize) @@ -51,8 +50,7 @@ class ReplayViewer extends PureComponent { handleResize = () => { const { clientWidth: width, clientHeight: height } = this.mount.current! - const { gameManager } = this.props - gameManager.updateSize(width, height) + dispatchCanvasResizeEvent({ width, height }) } toggleFullscreen = (enabled: boolean) => { @@ -91,6 +89,10 @@ const ViewerContainer = styled("div")({ width: "100%", height: 480, position: "relative", + "&& .fullscreen": { + width: "100%", + height: "100%", + }, }) const Viewer = styled("div")({ @@ -104,7 +106,6 @@ const Viewer = styled("div")({ const FullscreenWrapper = styled(FullScreen)({ width: "100%", - height: "100%", }) diff --git a/src/viewer/components/ScoreBoard.tsx b/src/viewer/components/ScoreBoard.tsx index 21a0434..4e1e120 100644 --- a/src/viewer/components/ScoreBoard.tsx +++ b/src/viewer/components/ScoreBoard.tsx @@ -2,12 +2,15 @@ import { styled } from "@material-ui/styles" import debounce from "lodash.debounce" import React, { PureComponent } from "react" +import { + addFrameListener, + FrameEvent, + removeFrameListener, +} from "../../eventbus/events/frame" import DataManager from "../../managers/DataManager" -import { GameManager } from "../../managers/GameManager" import { Goal } from "../../models/ReplayMetadata" import { getGameTime } from "../../operators/frameGetters" import { getPlayerById } from "../../operators/metadataGetters" -import { FPSClockSubscriberOptions } from "../../utils/FPSClock" interface Props {} interface State { @@ -18,7 +21,7 @@ interface State { export default class Scoreboard extends PureComponent { onFrame = debounce( - ({ frame }: FPSClockSubscriberOptions) => { + ({ frame }: FrameEvent) => { const { data } = DataManager.getInstance() const gameTime = getGameTime(data, frame) if (gameTime !== this.state.gameTime) { @@ -38,11 +41,11 @@ export default class Scoreboard extends PureComponent { gameTime: 300, } - GameManager.getInstance().clock.subscribe(this.onFrame) + addFrameListener(this.onFrame) } componentWillUnmount() { - GameManager.getInstance().clock.unsubscribe(this.onFrame) + removeFrameListener(this.onFrame) this.onFrame.cancel() } diff --git a/src/viewer/components/Slider.tsx b/src/viewer/components/Slider.tsx index cb73737..3004ff8 100644 --- a/src/viewer/components/Slider.tsx +++ b/src/viewer/components/Slider.tsx @@ -2,9 +2,13 @@ import MUISlider from "@material-ui/lab/Slider" import debounce from "lodash.debounce" import React, { Component } from "react" +import { + addFrameListener, + FrameEvent, + removeFrameListener, +} from "../../eventbus/events/frame" import DataManager from "../../managers/DataManager" import { GameManager } from "../../managers/GameManager" -import { FPSClockSubscriberOptions } from "../../utils/FPSClock" interface Props {} @@ -17,7 +21,7 @@ const SLIDER_OUTLINE_RADIUS = 24 class Slider extends Component { onFrame = debounce( - ({ frame }: FPSClockSubscriberOptions) => { + ({ frame }: FrameEvent) => { this.setState({ frame }) }, 250, @@ -30,11 +34,11 @@ class Slider extends Component { frame: 0, maxFrame: DataManager.getInstance().data.frames.length - 1, } - GameManager.getInstance().clock.subscribe(this.onFrame) + addFrameListener(this.onFrame) } componentWillUnmount() { - GameManager.getInstance().clock.unsubscribe(this.onFrame) + removeFrameListener(this.onFrame) this.onFrame.cancel() }