├── .babelrc ├── .editorconfig ├── .eslintrc.js ├── .github └── workflows │ ├── build-and-test.yml │ └── publish.yml ├── .gitignore ├── .gitmodules ├── .npmignore ├── .prettierrc ├── LICENSE ├── README.md ├── copy.js ├── docs ├── draco │ ├── draco_decoder.wasm │ └── draco_wasm_wrapper.js ├── examples │ ├── 80F9E0AA11E9EDD0CC415BA96B37926C │ │ ├── metadata.json │ │ └── positions.json │ └── positions.json ├── package-lock.json ├── package.json ├── src │ ├── App.tsx │ ├── components │ │ ├── CompactViewer.tsx │ │ ├── Empty.tsx │ │ ├── Main.tsx │ │ └── Viewer.tsx │ ├── index.html │ └── index.tsx ├── tsconfig.json ├── tslint.json └── webpack.config.js ├── package-lock.json ├── package.json ├── src ├── builders │ ├── AnimationBuilder.ts │ ├── GameBuilder.ts │ ├── SceneBuilder.ts │ ├── ball │ │ └── buildBall.ts │ ├── field │ │ ├── addCameras.ts │ │ └── buildPlayfield.ts │ ├── player │ │ ├── BasicPlayerBuilder.ts │ │ ├── PlayerBuilder.d.ts │ │ ├── generateSprite.ts │ │ └── positionWheels.ts │ ├── scene │ │ └── addLighting.ts │ └── utils │ │ ├── animationNameGetters.ts │ │ └── playerNameGetters.ts ├── constants │ ├── defaultCameraOptions.ts │ ├── eventNames.ts │ └── gameObjectNames.ts ├── eventbus │ ├── EventBus.ts │ └── events │ │ ├── cameraChange.ts │ │ ├── cameraFrameUpdate.ts │ │ ├── canvasResize.ts │ │ ├── frame.ts │ │ ├── keyControl.ts │ │ └── playPause.ts ├── index.ts ├── loaders │ ├── helpers │ │ ├── Panel.ts │ │ ├── Stats.ts │ │ └── index.ts │ ├── operators │ │ ├── getChildByName.ts │ │ ├── loadMaterial.ts │ │ ├── loadObject.ts │ │ └── throwLoadingError.ts │ ├── scenes │ │ ├── BasicCarAssets.ts │ │ └── GameFieldAssets.ts │ └── storage │ │ ├── loadBall.ts │ │ ├── loadCar.ts │ │ ├── loadField.ts │ │ └── storageMemoize.ts ├── managers │ ├── AnimationManager.ts │ ├── CameraManager.ts │ ├── DataManager.ts │ ├── DrawingManager.ts │ ├── GameManager.ts │ ├── KeyManager.ts │ ├── SceneManager.ts │ └── models │ │ ├── BallManager.ts │ │ ├── FieldManager.ts │ │ └── PlayerManager.ts ├── models │ ├── Game.ts │ ├── Replay.ts │ ├── ReplayData.ts │ ├── ReplayMetadata.ts │ └── ReplayPlayer.ts ├── operators │ ├── frameGetters.ts │ ├── isOrthographicCamera.ts │ └── metadataGetters.ts ├── types │ ├── glb.d.ts │ └── json.d.ts ├── utils │ ├── FPSClock.ts │ ├── addToWindow.ts │ ├── hashCode.ts │ └── isDevelopment.ts ├── viewer │ ├── clients │ │ ├── loadBuilderFromReplay.ts │ │ └── loadReplay.ts │ └── components │ │ ├── CompactPlayControls.tsx │ │ ├── DrawingControls.tsx │ │ ├── FieldCameraControls.tsx │ │ ├── GameManagerLoader.tsx │ │ ├── PlayControls.tsx │ │ ├── PlayerCameraControls.tsx │ │ ├── ReplayViewer.tsx │ │ ├── ScoreBoard.tsx │ │ ├── Slider.tsx │ │ └── icons │ │ ├── BoxIcon.tsx │ │ ├── Camera.tsx │ │ ├── ColorIcon.tsx │ │ ├── DeleteIcon.tsx │ │ ├── DropDownIcon.tsx │ │ ├── ErrorIcon.tsx │ │ ├── FullscreenExitIcon.tsx │ │ ├── FullscreenIcon.tsx │ │ ├── LineIcon.tsx │ │ ├── PausedIcon.tsx │ │ ├── PencilIcon.tsx │ │ ├── PencilOffIcon.tsx │ │ ├── PlayIcon.tsx │ │ └── SphereIcon.tsx └── wrapper │ └── withGameManager.ts ├── tsconfig.json ├── tslint.json └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-typescript", 4 | "@babel/preset-env", 5 | "@babel/preset-react" 6 | ], 7 | "plugins": [ 8 | "@babel/plugin-proposal-class-properties", 9 | "dynamic-import-webpack" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | indent_style = space 10 | insert_final_newline = true 11 | 12 | [*.{js,ts,jsx,tsx}] 13 | indent_size = 2 -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "node": true 5 | }, 6 | "extends": [ 7 | "plugin:@typescript-eslint/recommended", 8 | "plugin:@typescript-eslint/recommended-requiring-type-checking" 9 | ], 10 | "parser": "@typescript-eslint/parser", 11 | "parserOptions": { 12 | "project": "tsconfig.json", 13 | "sourceType": "module" 14 | }, 15 | "plugins": [ 16 | "@typescript-eslint", 17 | "@typescript-eslint/tslint" 18 | ], 19 | "rules": { 20 | "@typescript-eslint/adjacent-overload-signatures": "warn", 21 | "@typescript-eslint/array-type": "warn", 22 | "@typescript-eslint/ban-types": "warn", 23 | "@typescript-eslint/class-name-casing": "warn", 24 | "@typescript-eslint/consistent-type-assertions": "warn", 25 | "@typescript-eslint/explicit-member-accessibility": [ 26 | "off", 27 | { 28 | "accessibility": "explicit" 29 | } 30 | ], 31 | "@typescript-eslint/indent": "warn", 32 | "@typescript-eslint/interface-name-prefix": "off", 33 | "@typescript-eslint/member-delimiter-style": [ 34 | "warn", 35 | { 36 | "multiline": { 37 | "delimiter": "none", 38 | "requireLast": true 39 | }, 40 | "singleline": { 41 | "delimiter": "semi", 42 | "requireLast": false 43 | } 44 | } 45 | ], 46 | "@typescript-eslint/member-ordering": "warn", 47 | "@typescript-eslint/no-empty-function": "warn", 48 | "@typescript-eslint/no-empty-interface": "off", 49 | "@typescript-eslint/no-explicit-any": "off", 50 | "@typescript-eslint/no-extraneous-class": "warn", 51 | "@typescript-eslint/no-misused-new": "warn", 52 | "@typescript-eslint/no-namespace": "off", 53 | "@typescript-eslint/no-parameter-properties": "off", 54 | "@typescript-eslint/no-require-imports": "warn", 55 | "@typescript-eslint/no-this-alias": "off", 56 | "@typescript-eslint/no-use-before-define": "off", 57 | "@typescript-eslint/no-var-requires": "warn", 58 | "@typescript-eslint/prefer-for-of": "warn", 59 | "@typescript-eslint/prefer-function-type": "warn", 60 | "@typescript-eslint/prefer-namespace-keyword": "warn", 61 | "@typescript-eslint/prefer-readonly": "warn", 62 | "@typescript-eslint/quotes": [ 63 | "warn", 64 | "double", 65 | { 66 | "avoidEscape": true 67 | } 68 | ], 69 | "@typescript-eslint/semi": [ 70 | "warn", 71 | "never" 72 | ], 73 | "@typescript-eslint/triple-slash-reference": "warn", 74 | "@typescript-eslint/unified-signatures": "warn", 75 | "arrow-body-style": "warn", 76 | "arrow-parens": [ 77 | "off", 78 | "as-needed" 79 | ], 80 | "camelcase": "warn", 81 | "comma-dangle": "off", 82 | "complexity": "off", 83 | "constructor-super": "warn", 84 | "curly": "warn", 85 | "dot-notation": "warn", 86 | "eol-last": "warn", 87 | "eqeqeq": [ 88 | "warn", 89 | "smart" 90 | ], 91 | "guard-for-in": "warn", 92 | "id-blacklist": [ 93 | "warn", 94 | "any", 95 | "Number", 96 | "number", 97 | "String", 98 | "string", 99 | "Boolean", 100 | "boolean", 101 | "Undefined", 102 | "undefined" 103 | ], 104 | "id-match": "warn", 105 | "import/no-default-export": "off", 106 | "import/order": "warn", 107 | "max-classes-per-file": [ 108 | "warn", 109 | 1 110 | ], 111 | "max-len": [ 112 | "warn", 113 | { 114 | "code": 120 115 | } 116 | ], 117 | "new-parens": "warn", 118 | "newline-per-chained-call": "off", 119 | "no-bitwise": "warn", 120 | "no-caller": "warn", 121 | "no-cond-assign": "warn", 122 | "no-console": "off", 123 | "no-debugger": "warn", 124 | "no-duplicate-imports": "warn", 125 | "no-empty": "warn", 126 | "no-eval": "warn", 127 | "no-fallthrough": "off", 128 | "no-invalid-this": "warn", 129 | "no-multiple-empty-lines": "warn", 130 | "no-new-wrappers": "warn", 131 | "no-redeclare": "warn", 132 | "no-shadow": [ 133 | "warn", 134 | { 135 | "hoist": "all" 136 | } 137 | ], 138 | "no-throw-literal": "warn", 139 | "no-trailing-spaces": "warn", 140 | "no-undef-init": "warn", 141 | "no-underscore-dangle": "warn", 142 | "no-unsafe-finally": "warn", 143 | "no-unused-expressions": "warn", 144 | "no-unused-labels": "warn", 145 | "no-var": "warn", 146 | "no-void": "warn", 147 | "object-shorthand": "warn", 148 | "one-var": [ 149 | "warn", 150 | "never" 151 | ], 152 | "prefer-arrow/prefer-arrow-functions": "warn", 153 | "prefer-const": "warn", 154 | "quote-props": "off", 155 | "radix": "warn", 156 | "space-before-function-paren": [ 157 | "warn", 158 | { 159 | "anonymous": "always", 160 | "named": "never", 161 | "asyncArrow": "always" 162 | } 163 | ], 164 | "spaced-comment": "warn", 165 | "use-isnan": "warn", 166 | "valid-typeof": "off", 167 | "@typescript-eslint/tslint/config": [ 168 | "error", 169 | { 170 | "rules": { 171 | "jsdoc-format": true, 172 | "jsx-curly-spacing": [ 173 | true, 174 | "never" 175 | ], 176 | "jsx-equals-spacing": [ 177 | true, 178 | "never" 179 | ], 180 | "jsx-key": true, 181 | "jsx-no-bind": true, 182 | "jsx-no-string-ref": true, 183 | "jsx-self-close": true, 184 | "jsx-wrap-multiline": true, 185 | "no-reference-import": true, 186 | "one-line": [ 187 | true, 188 | "check-catch", 189 | "check-else", 190 | "check-finally", 191 | "check-open-brace", 192 | "check-whitespace" 193 | ], 194 | "whitespace": [ 195 | true, 196 | "check-branch", 197 | "check-decl", 198 | "check-operator", 199 | "check-module", 200 | "check-separator", 201 | "check-rest-spread", 202 | "check-type", 203 | "check-typecast", 204 | "check-type-operator", 205 | "check-preblock" 206 | ] 207 | } 208 | } 209 | ] 210 | } 211 | }; 212 | -------------------------------------------------------------------------------- /.github/workflows/build-and-test.yml: -------------------------------------------------------------------------------- 1 | name: "Build and Test" 2 | 3 | on: 4 | push: 5 | paths: 6 | - 'src/*' 7 | - 'docs/*' 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: Install packages 17 | run: npm ci 18 | - name: Build the project 19 | run: npm run build 20 | - name: Run linter 21 | uses: mooyoul/tslint-actions@v1.1.1 22 | with: 23 | token: ${{ secrets.GITHUB_TOKEN }} 24 | pattern: '*.ts' 25 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to NPM 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout project 13 | uses: actions/checkout@v1 14 | - name: Install packages 15 | run: npm ci 16 | - name: Build the project 17 | run: npm run build 18 | - name: Add _auth to .npmrc 19 | run: npm config set '//registry.npmjs.org/:_authToken' "${NODE_AUTH_TOKEN}" 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 23 | - name: Publish to NPM 24 | run: npm run publish:lib 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Editor 2 | *.iml 3 | 4 | # NPM 5 | node_modules/ 6 | 7 | # Build files 8 | lib/ 9 | lib-esm/ 10 | dist/ 11 | _bundles/ 12 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "src/assets"] 2 | path = src/assets 3 | url = https://github.com/SaltieRL/SharedAssets.git 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .* 2 | **/tsconfig.json 3 | **/webpack.config.js 4 | node_modules 5 | src 6 | examples 7 | copy.js 8 | docs 9 | types 10 | rollup.config.ts 11 | **/archive 12 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": false, 6 | "quote-props": [1, "always"] 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Saltie 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Replay Viewer 2 | 3 | [![NPM package][npm]][npm-url] 4 | [![Language Grade][lgtm]][lgtm-url] 5 | [![Discord][discord]][discord-url] 6 | 7 | [npm]: https://img.shields.io/npm/v/replay-viewer 8 | [npm-url]: https://www.npmjs.com/package/replay-viewer 9 | [lgtm]: https://img.shields.io/lgtm/grade/javascript/github/SaltieRL/WebReplayViewer.svg?label=code%20quality 10 | [lgtm-url]: https://lgtm.com/projects/g/SaltieRL/WebReplayViewer/ 11 | [discord]: https://img.shields.io/discord/482991399017512960.svg?colorB=7581dc&logo=discord&logoColor=white 12 | [discord-url]: https://discord.gg/EaFRh7v 13 | 14 | An extension of the [DistributedReplays](https://github.com/SaltieRL/DistributedReplays) website, this library aims to provide a React component + utilities for displaying a Rocket League replay viewer in WebGL using Three.js 15 | 16 | ## Setup 17 | 18 | ### Unix 19 | 20 | First thing's first: install all of your dependencies. We rely on [Webpack](https://webpack.js.org/) as a bundler and [Babel](https://babeljs.io/) as a transpiler, so most dependencies are plugins for this project. 21 | 22 | ```bash 23 | $ npm install 24 | ``` 25 | 26 | ### Developing 27 | 28 | To launch a hot-reloading configuration, run the following. All file changes will be detected and will cause the package to re-bundle. The examples current live inside of the `docs/` folder but can be modified to show off newer features. 29 | 30 | ```bash 31 | $ npm start 32 | ``` 33 | 34 | If you would like to test with a compiled version of the app, you can run the following command to link the package globally. Then, you can import the bundle as you normally would by replacing instances of `../src/foo` with `replay-viewer/foo`. This will tell the `docs/` directory to treat this package as though it were installed inside your `node_modules/` directory. 35 | 36 | ```bash 37 | $ npm run link 38 | ``` 39 | 40 | ### Building 41 | 42 | After ensuring that your build successfully compiles, you can build using the pre-configured build scripts. When you're ready to build, the build scripts will generate typescript declaration files and source maps inside the `lib/` folder for your consumption. When in doubt, look at where the `main` value is set in the `package.json` file. 43 | 44 | ```bash 45 | $ npm run build 46 | ``` 47 | -------------------------------------------------------------------------------- /copy.js: -------------------------------------------------------------------------------- 1 | const path = require("path") 2 | const fse = require("fs-extra") 3 | 4 | async function copyReadme() { 5 | return fse 6 | .copyFile( 7 | path.resolve(__dirname, "README.md"), 8 | path.resolve(__dirname, "lib", "README.md") 9 | ) 10 | .then(() => console.log("Copied README.md")) 11 | } 12 | 13 | async function copyPackageJson() { 14 | return new Promise(resolve => { 15 | fse.readFile( 16 | path.resolve(__dirname, "package.json"), 17 | "utf-8", 18 | (err, data) => { 19 | if (err) throw err 20 | resolve(data) 21 | } 22 | ) 23 | }) 24 | .then(data => JSON.parse(data)) 25 | .then(packageData => { 26 | const { 27 | devDependencies, 28 | main, 29 | types, 30 | module, 31 | scripts, 32 | ...other 33 | } = packageData 34 | 35 | const newPackage = { 36 | ...other, 37 | private: false, 38 | main: "./index.js", 39 | typings: "./index.d.ts", 40 | } 41 | 42 | return new Promise(resolve => { 43 | const buildPath = path.resolve(__dirname, "lib", "package.json") 44 | const data = JSON.stringify(newPackage, null, 2) 45 | fse.writeFile(buildPath, data, err => { 46 | if (err) throw err 47 | console.log(`Created package.json in ${buildPath}`) 48 | resolve() 49 | }) 50 | }) 51 | }) 52 | } 53 | 54 | copyPackageJson() 55 | .then(copyReadme) 56 | .catch(console.error) 57 | -------------------------------------------------------------------------------- /docs/draco/draco_decoder.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SaltieRL/WebReplayViewer/21e392d9f319c146b84e14340d85f5720bd18c5f/docs/draco/draco_decoder.wasm -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "version": "1.0.0", 4 | "description": "Examples for the web replay viewer", 5 | "main": "src/index.tsx", 6 | "scripts": { 7 | "start": "npx webpack-dev-server --progress --mode development --port 4000 --output-pathinfo --hot" 8 | }, 9 | "keywords": [ 10 | "Examples", 11 | "Replay", 12 | "Viewer", 13 | "React" 14 | ], 15 | "author": "Saltie", 16 | "license": "MIT", 17 | "devDependencies": { 18 | "@types/react-dom": "^17.0.14", 19 | "file-loader": "^6.2.0", 20 | "html-webpack-plugin": "^5.5.0", 21 | "typescript": "^4.6.3", 22 | "webpack": "^5.70.0", 23 | "webpack-dev-server": "^4.7.4", 24 | "ts-loader": "^9.2.8" 25 | }, 26 | "browserslist": [ 27 | ">0.2%", 28 | "not dead", 29 | "not ie <= 11", 30 | "not op_mini all" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /docs/src/App.tsx: -------------------------------------------------------------------------------- 1 | import Tab from "@mui/material/Tab" 2 | import Tabs from "@mui/material/Tabs" 3 | import React, { Component } from "react" 4 | 5 | import Main from "./components/Main" 6 | 7 | type ActiveTab = "viewer" | "compact" | "other" 8 | 9 | interface State { 10 | tab: ActiveTab 11 | } 12 | 13 | class App extends Component { 14 | constructor(props: any) { 15 | super(props) 16 | this.state = { 17 | tab: "viewer", 18 | } 19 | } 20 | 21 | handleChange = (_: any, newTab: ActiveTab) => { 22 | this.setState({ 23 | tab: newTab, 24 | }) 25 | } 26 | 27 | render() { 28 | const { tab } = this.state 29 | return ( 30 |
31 |
32 |

Welcome to Replay Viewer

33 |
34 | 35 | 36 | 37 | 38 | 39 | {tab === "viewer" &&
} 40 | {tab === "compact" &&
} 41 | {tab === "other" &&
Other
} 42 |
43 | ) 44 | } 45 | } 46 | 47 | export default App 48 | -------------------------------------------------------------------------------- /docs/src/components/CompactViewer.tsx: -------------------------------------------------------------------------------- 1 | import Grid from "@mui/material/Grid" 2 | import React, { Component } from "react" 3 | 4 | import { 5 | CompactPlayControls, 6 | GameBuilderOptions, 7 | GameManager, 8 | GameManagerLoader, 9 | ReplayViewer, 10 | } from "../../../src" 11 | 12 | interface Props { 13 | options: GameBuilderOptions 14 | } 15 | 16 | interface State { 17 | gameManager?: GameManager 18 | } 19 | 20 | class CompactViewer extends Component { 21 | constructor(props: Props) { 22 | super(props) 23 | this.state = {} 24 | } 25 | 26 | renderContent() { 27 | const { gameManager } = this.state 28 | 29 | if (!gameManager) { 30 | return "Food machine broke..." 31 | } 32 | 33 | return ( 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | ) 42 | } 43 | 44 | render() { 45 | const { options } = this.props 46 | const onLoad = (gm: GameManager) => this.setState({ gameManager: gm }) 47 | return ( 48 | 49 | {this.renderContent()} 50 | 51 | ) 52 | } 53 | } 54 | 55 | export default CompactViewer 56 | -------------------------------------------------------------------------------- /docs/src/components/Empty.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | const Empty = () => { 4 | return
Empty component
5 | } 6 | 7 | export default Empty 8 | -------------------------------------------------------------------------------- /docs/src/components/Main.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component, ErrorInfo } from "react" 2 | 3 | import { FPSClock, GameBuilderOptions, loadReplay } from "../../../src" 4 | import CompactViewer from "./CompactViewer" 5 | import Viewer from "./Viewer" 6 | 7 | interface Props { 8 | compact?: boolean 9 | } 10 | 11 | interface State { 12 | options?: GameBuilderOptions 13 | error?: Error 14 | errorInfo?: ErrorInfo 15 | } 16 | 17 | class Main extends Component { 18 | constructor(props: Props) { 19 | super(props) 20 | this.state = {} 21 | } 22 | 23 | componentDidMount() { 24 | const REPLAY_ID = "80F9E0AA11E9EDD0CC415BA96B37926C" 25 | 26 | loadReplay(REPLAY_ID, true, true).then(([replayData, replayMetadata]) => { 27 | this.setState({ 28 | options: { 29 | replayData, 30 | replayMetadata, 31 | clock: FPSClock.convertReplayToClock(replayData), 32 | defaultLoadouts: false, 33 | useBallRotation: false, 34 | }, 35 | }) 36 | }) 37 | } 38 | 39 | componentDidCatch(error: Error, errorInfo: ErrorInfo) { 40 | this.setState({ error, errorInfo }) 41 | } 42 | 43 | render() { 44 | const { options, error, errorInfo } = this.state 45 | 46 | if (!options) { 47 | return "Loading..." 48 | } else if (error || errorInfo) { 49 | return JSON.stringify(error) 50 | } 51 | 52 | return this.props.compact ? ( 53 | 54 | ) : ( 55 | 56 | ) 57 | } 58 | } 59 | 60 | export default Main 61 | -------------------------------------------------------------------------------- /docs/src/components/Viewer.tsx: -------------------------------------------------------------------------------- 1 | import Grid from "@mui/material/Grid" 2 | import React, { Component } from "react" 3 | 4 | import { 5 | DrawingControls, 6 | FieldCameraControls, 7 | GameBuilderOptions, 8 | GameManager, 9 | GameManagerLoader, 10 | PlayControls, 11 | PlayerCameraControls, 12 | ReplayViewer, 13 | Slider, 14 | } from "../../../src" 15 | 16 | interface Props { 17 | options: GameBuilderOptions 18 | } 19 | 20 | interface State { 21 | gameManager?: GameManager 22 | } 23 | 24 | export default class Viewer extends Component { 25 | constructor(props: Props) { 26 | super(props) 27 | this.state = {} 28 | } 29 | 30 | renderContent() { 31 | const { gameManager } = this.state 32 | 33 | if (!gameManager) { 34 | return "Food machine broke..." 35 | } 36 | 37 | return ( 38 | div:nth-child(even)": { 42 | backgroundColor: "rgba(0, 0, 0, 0.05)", 43 | }, 44 | }} 45 | direction="column" 46 | justifyContent="center" 47 | spacing={3} 48 | > 49 | 50 | 51 | 52 | 53 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | ) 78 | } 79 | 80 | render() { 81 | const { options } = this.props 82 | const onLoad = (gm: GameManager) => this.setState({ gameManager: gm }) 83 | return ( 84 | 85 | {this.renderContent()} 86 | 87 | ) 88 | } 89 | } 90 | 91 | -------------------------------------------------------------------------------- /docs/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 13 | Replay Viewer | calculated.gg 14 | 15 | 16 | 19 |
20 | 21 | 22 | -------------------------------------------------------------------------------- /docs/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import ReactDOM from "react-dom" 3 | import App from "./App" 4 | 5 | ReactDOM.render(, document.getElementById("root") as HTMLElement) 6 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "commonjs", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "noEmit": false, 15 | "jsx": "react", 16 | "preserveSymlinks": true 17 | }, 18 | "include": ["./src/**/*.ts*"] 19 | } 20 | -------------------------------------------------------------------------------- /docs/tslint.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /docs/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"), 2 | HtmlWebpackPlugin = require("html-webpack-plugin") 3 | 4 | module.exports = { 5 | entry: { 6 | app: [path.resolve(__dirname, "src/index.tsx")], 7 | vendor: ["react", "react-dom"], 8 | }, 9 | output: { 10 | filename: "[name].bundle.js", 11 | path: path.resolve(__dirname, "dist"), 12 | publicPath: "/", 13 | }, 14 | resolve: { 15 | extensions: [".js", ".jsx", ".json", ".ts", ".tsx"], 16 | }, 17 | optimization: { 18 | removeAvailableModules: false, 19 | removeEmptyChunks: false, 20 | splitChunks: false, 21 | }, 22 | module: { 23 | rules: [ 24 | { 25 | test: /\.(ts|tsx)$/, 26 | loader: "ts-loader", 27 | }, 28 | { 29 | test: /\.(glb|mtl|png|jpe?g|gif)$/, 30 | use: [ 31 | { 32 | loader: "file-loader", 33 | options: {}, 34 | }, 35 | ], 36 | }, 37 | ], 38 | }, 39 | plugins: [ 40 | new HtmlWebpackPlugin({ 41 | template: path.resolve(__dirname, "src/index.html"), 42 | }), 43 | ], 44 | devtool: "source-map", 45 | devServer: { 46 | static: { 47 | directory: __dirname, 48 | }, 49 | compress: true, 50 | port: 4000, 51 | }, 52 | } 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "replay-viewer", 3 | "version": "0.9.1", 4 | "description": "Rocket League replay viewer React component and tooling", 5 | "main": "./lib/index.js", 6 | "types": "./lib/index.d.ts", 7 | "scripts": { 8 | "postinstall": "npm run submodule:init && cd docs && npm install", 9 | "clean": "rimraf _bundles lib", 10 | "lint": "tslint src/**/*.ts{,x} -p tsconfig.json", 11 | "build": "npm run clean && npm run build:tsc && npm run build:bundle && npm run build:copy", 12 | "build:tsc": "tsc", 13 | "build:bundle": "npx webpack", 14 | "build:copy": "node copy.js", 15 | "start": "cd docs && npm run start", 16 | "link": "npm link && cd docs && npm link replay-viewer", 17 | "submodule": "git submodule update --remote", 18 | "submodule:init": "git submodule update --init --remote", 19 | "publish:lib": "cd lib && npm publish", 20 | "prepublishOnly": "echo 'Do not publish from root. Publish from lib'; exit 1" 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/SaltieRL/WebReplayViewer.git" 25 | }, 26 | "keywords": [ 27 | "Rocket", 28 | "League", 29 | "Replay", 30 | "React", 31 | "Three", 32 | "WebGL" 33 | ], 34 | "author": "SaltieRL", 35 | "license": "MIT", 36 | "bugs": { 37 | "url": "https://github.com/SaltieRL/WebReplayViewer/issues" 38 | }, 39 | "homepage": "https://github.com/SaltieRL/WebReplayViewer#readme", 40 | "peerDependencies": { 41 | "@material-ui/core": "^4.9.7", 42 | "react": "^17.0.2", 43 | "react-dom": "^17.0.2", 44 | "three": "^0.139.0" 45 | }, 46 | "dependencies": { 47 | "lodash.debounce": "^4.0.8", 48 | "moment": "^2.29.1", 49 | "pngjs": "^6.0.0", 50 | "react-full-screen": "^1.1.0" 51 | }, 52 | "devDependencies": { 53 | "@babel/core": "^7.17.8", 54 | "@babel/plugin-proposal-class-properties": "^7.16.7", 55 | "@babel/preset-env": "^7.16.11", 56 | "@babel/preset-react": "^7.16.7", 57 | "@babel/preset-typescript": "^7.16.7", 58 | "@emotion/react": "^11.9.0", 59 | "@emotion/styled": "^11.8.1", 60 | "@mui/material": "^5.4.1", 61 | "@types/lodash.debounce": "^4.0.6", 62 | "@types/node": "^17.0.23", 63 | "@types/react": "^17.0.43", 64 | "@types/styled-components": "^5.1.24", 65 | "@types/three": "^0.139.0", 66 | "babel-loader": "^8.2.4", 67 | "babel-plugin-dynamic-import-webpack": "^1.1.0", 68 | "file-loader": "^6.2.0", 69 | "fs-extra": "^10.0.1", 70 | "lodash.camelcase": "^4.3.0", 71 | "react": "^17.0.2", 72 | "react-dom": "^17.0.2", 73 | "rimraf": "^3.0.2", 74 | "styled-components": "^5.3.5", 75 | "three": "^0.139.0", 76 | "typescript": "^4.6.3", 77 | "webpack": "^5.70.0", 78 | "webpack-cli": "^4.9.2", 79 | "webpack-dev-server": "^4.7.4" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/builders/AnimationBuilder.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AnimationClip, 3 | AnimationMixer, 4 | Euler, 5 | Quaternion, 6 | QuaternionKeyframeTrack, 7 | Vector3, 8 | VectorKeyframeTrack, 9 | } from "three" 10 | 11 | import { BALL } from "../constants/gameObjectNames" 12 | import AnimationManager from "../managers/AnimationManager" 13 | import BallManager from "../managers/models/BallManager" 14 | import PlayerManager from "../managers/models/PlayerManager" 15 | import { ReplayData } from "../models/ReplayData" 16 | import { 17 | getActionClipName, 18 | getPositionName, 19 | getRotationName, 20 | } from "./utils/animationNameGetters" 21 | import { getCarName, getGroupName } from "./utils/playerNameGetters" 22 | 23 | interface KeyframeData { 24 | duration: number 25 | positionValues: number[] 26 | positionTimes: number[] 27 | rotationValues: number[] 28 | rotationTimes: number[] 29 | } 30 | 31 | /** 32 | * Class is responsible for all position and rotation-based updating of models that occur inside 33 | * the three.js scene. Builds animation mixers that are used to display animations but do not 34 | * directly interact with the models themselves outside of the required naming conventions that 35 | * keyframe tracks provide. 36 | */ 37 | const defaultAnimationBuilder = ( 38 | replayData: ReplayData, 39 | playerModels: PlayerManager[], 40 | ballModel: BallManager, 41 | useBallRotation: boolean = true, 42 | ): AnimationManager => { 43 | /** 44 | * Replay data is of this form: 45 | * [posX, posZ, posY, rotX, rotZ, royY] 46 | * 47 | * Three is RH as opposed to Unreal/Unity's LH axes and uses y as the up axis. All angles 48 | * are in the range -PI to PI. 49 | * 50 | * For parsed data information, see: 51 | * https://github.com/SaltieRL/carball/blob/master/carball/json_parser/actor_parsing.py#L107 52 | * 53 | */ 54 | const dataToVector = (data: any[]) => { 55 | const x = data[0] 56 | const y = data[2] 57 | const z = data[1] 58 | return new Vector3(x, y, z) 59 | } 60 | const dataToQuaternion = (data: any[]) => { 61 | const q = new Quaternion() 62 | const x = -data[3] 63 | const y = -data[5] 64 | const z = -data[4] 65 | q.setFromEuler(new Euler(y, z, x, "YZX")) 66 | return q 67 | } 68 | const generateKeyframeData = (posRotData: any[]): KeyframeData => { 69 | const positions: number[] = [] 70 | const rotations: number[] = [] 71 | /** 72 | * We are calculating vector and quaternion times independently because there are often 73 | * cases where the data of a frame might coincide with the data of the next frame. The 74 | * replays may not contain data for every frame, so carball inserts the previous frame 75 | * data into the following frame as to avoid any missing frames. Here, we assume the 76 | * entire duration of that missing frame must be animated. 77 | * 78 | * For example, if a car's position at 1.1 seconds reads (45, 100, 500), at 1.2 seconds 79 | * reads (45, 100, 500), and at 1.3 seconds reads (50, 110, 400), we can assume the 80 | * middle frame was skipped and so we need to perform animation between 1.1 seconds and 81 | * 1.3 seconds instead of including the jitter of the car remaining in position from 1.1 82 | * seconds to 1.2 seconds. 83 | * 84 | * We perform the second duration vs. last frame calculation check because we consider 85 | * kickoffs as a valid reason to not treat the first position traveled to as a valid 86 | * three second animation. Otherwise, the car will slowly creep forward during kickoff. 87 | * A kickoff occurs for about three seconds, so 2.9 ensures we break off just before a 88 | * kickoff occurs. 89 | */ 90 | let totalDuration = 0 91 | const positionTimes: number[] = [] 92 | const rotationTimes: number[] = [] 93 | let prevVector = new Vector3(0, 0, 0) 94 | let prevQuat = new Quaternion(0, 0, 0, 0) 95 | posRotData.forEach((data, index) => { 96 | // Apply position frame 97 | const newVector = dataToVector(data) 98 | const lastVectorFrame = positionTimes.length 99 | ? positionTimes[positionTimes.length - 1] 100 | : 0 101 | if ( 102 | !newVector.equals(prevVector) || 103 | totalDuration - lastVectorFrame > 2.9 104 | ) { 105 | newVector.toArray(positions, positions.length) 106 | positionTimes.push(totalDuration) 107 | prevVector = newVector 108 | } 109 | // Apply rotation frame 110 | const newQuat = dataToQuaternion(data) 111 | const lastQuatFrame = rotationTimes.length 112 | ? rotationTimes[rotationTimes.length - 1] 113 | : 0 114 | if (!newQuat.equals(prevQuat) || totalDuration - lastQuatFrame > 2.9) { 115 | newQuat.toArray(rotations, rotations.length) 116 | rotationTimes.push(totalDuration) 117 | prevQuat = newQuat 118 | } 119 | // Add the delta 120 | totalDuration += replayData.frames[index][0] 121 | }) 122 | 123 | return { 124 | duration: totalDuration, 125 | positionTimes, 126 | positionValues: positions, 127 | rotationTimes, 128 | rotationValues: rotations, 129 | } 130 | } 131 | 132 | const playerClips = [] 133 | // First, generate player clips 134 | for (let player = 0; player < replayData.players.length; player++) { 135 | const playerData = replayData.players[player] 136 | const playerName = `${replayData.names[player]}` 137 | const playerKeyframeData = generateKeyframeData(playerData) 138 | 139 | // Note that Three.JS requires this .position/.quaternion naming convention, and that 140 | // the object we wish to modify must have this associated name. 141 | const playerPosKeyframes = new VectorKeyframeTrack( 142 | getPositionName(getGroupName(playerName)), 143 | playerKeyframeData.positionTimes, 144 | playerKeyframeData.positionValues 145 | ) 146 | const playerRotKeyframes = new QuaternionKeyframeTrack( 147 | getRotationName(getCarName(playerName)), 148 | playerKeyframeData.rotationTimes, 149 | playerKeyframeData.rotationValues 150 | ) 151 | 152 | const playerClip = new AnimationClip( 153 | getActionClipName(playerName), 154 | playerKeyframeData.duration, 155 | [playerPosKeyframes, playerRotKeyframes] 156 | ) 157 | playerClips.push(playerClip) 158 | } 159 | // Then, generate the ball clip 160 | const ballData = replayData.ball 161 | const ballKeyframeData = generateKeyframeData(ballData) 162 | 163 | const ballPosKeyframes = new VectorKeyframeTrack( 164 | getPositionName(BALL), 165 | ballKeyframeData.positionTimes, 166 | ballKeyframeData.positionValues 167 | ) 168 | const ballRotKeyframes = new QuaternionKeyframeTrack( 169 | getRotationName(BALL), 170 | ballKeyframeData.rotationTimes, 171 | ballKeyframeData.rotationValues 172 | ) 173 | const ballClip = new AnimationClip( 174 | getActionClipName(BALL), 175 | ballKeyframeData.duration, 176 | useBallRotation ? [ballPosKeyframes, ballRotKeyframes] : [ballPosKeyframes], 177 | ) 178 | return AnimationManager.init({ 179 | playerClips, 180 | ballClip, 181 | playerMixers: playerModels.map(model => new AnimationMixer(model.carGroup)), 182 | ballMixer: new AnimationMixer(ballModel.ball), 183 | }) 184 | } 185 | 186 | export default defaultAnimationBuilder 187 | -------------------------------------------------------------------------------- /src/builders/GameBuilder.ts: -------------------------------------------------------------------------------- 1 | import { LoadingManager } from "three" 2 | 3 | import CameraManager from "../managers/CameraManager" 4 | import DataManager from "../managers/DataManager" 5 | import { GameManager } from "../managers/GameManager" 6 | import KeyManager from "../managers/KeyManager" 7 | import { ReplayData } from "../models/ReplayData" 8 | import { ReplayMetadata } from "../models/ReplayMetadata" 9 | import FPSClock from "../utils/FPSClock" 10 | import defaultAnimationBuilder from "./AnimationBuilder" 11 | import defaultSceneBuilder from "./SceneBuilder" 12 | 13 | export interface GameBuilderOptions { 14 | replayData: ReplayData 15 | replayMetadata: ReplayMetadata 16 | clock: FPSClock 17 | loadingManager?: LoadingManager 18 | defaultLoadouts: boolean 19 | useBallRotation?: boolean 20 | } 21 | 22 | const defaultGameBuilder = async ({ 23 | clock, 24 | replayData, 25 | replayMetadata, 26 | loadingManager, 27 | useBallRotation = true, 28 | }: GameBuilderOptions) => { 29 | const players = replayMetadata.players 30 | try { 31 | const sceneManager = await defaultSceneBuilder( 32 | players, 33 | loadingManager, 34 | ) 35 | defaultAnimationBuilder(replayData, sceneManager.players, sceneManager.ball, useBallRotation) 36 | DataManager.init({ replayData, replayMetadata }) 37 | CameraManager.init() 38 | KeyManager.init() 39 | 40 | return GameManager.init({ 41 | clock, 42 | }) 43 | } catch (e: any) { 44 | loadingManager?.onError && loadingManager?.onError(e.toString()) 45 | throw e 46 | } 47 | } 48 | 49 | export default defaultGameBuilder 50 | -------------------------------------------------------------------------------- /src/builders/SceneBuilder.ts: -------------------------------------------------------------------------------- 1 | import { Cache, LoadingManager, Scene } from "three" 2 | 3 | import GameFieldAssets from "../loaders/scenes/GameFieldAssets" 4 | import PlayerManager from "../managers/models/PlayerManager" 5 | import SceneManager from "../managers/SceneManager" 6 | import { ExtendedPlayer } from "../models/ReplayMetadata" 7 | import { buildBall } from "./ball/buildBall" 8 | import { buildPlayfield } from "./field/buildPlayfield" 9 | import BasicPlayerBuilder from "./player/BasicPlayerBuilder" 10 | import { addLighting } from "./scene/addLighting" 11 | 12 | const getPlayersAndScene = async ( 13 | playerInfo: ExtendedPlayer[], 14 | loadingManager?: LoadingManager, 15 | ): Promise<[PlayerManager[], Scene]> => { 16 | const basicBuilder = new BasicPlayerBuilder(loadingManager) 17 | const scene = new Scene() 18 | const players = await basicBuilder.buildPlayers(scene, playerInfo) 19 | return [players, scene] 20 | } 21 | 22 | /** 23 | * @description The sole purpose of this function is to initialize and tie together all of the 24 | * required assets for the replay viewer. This includes resizing and lighting to ensure that every 25 | * object is of the correct color and size. Out of necessity, this function is what loads all 26 | * required game assets. 27 | */ 28 | const defaultSceneBuilder = async ( 29 | playerInfo: ExtendedPlayer[], 30 | loadingManager?: LoadingManager, 31 | ): Promise => { 32 | // Enabled caching used by three's loaders 33 | Cache.enabled = true 34 | 35 | if (loadingManager) { 36 | GameFieldAssets.loadingManager = loadingManager 37 | } 38 | await GameFieldAssets.load() 39 | 40 | const [players, scene] = await getPlayersAndScene( 41 | playerInfo, 42 | loadingManager, 43 | ) 44 | 45 | addLighting(scene) 46 | const field = buildPlayfield(scene) 47 | const ball = buildBall(scene) 48 | 49 | return SceneManager.init({ 50 | scene, 51 | ball, 52 | field, 53 | players, 54 | }) 55 | } 56 | 57 | export default defaultSceneBuilder 58 | -------------------------------------------------------------------------------- /src/builders/ball/buildBall.ts: -------------------------------------------------------------------------------- 1 | import { Scene } from "three" 2 | 3 | import { BALL } from "../../constants/gameObjectNames" 4 | import GameFieldAssets from "../../loaders/scenes/GameFieldAssets" 5 | import BallManager from "../../managers/models/BallManager" 6 | 7 | export const buildBall = (scene: Scene) => { 8 | const { ball } = GameFieldAssets.getAssets() 9 | ball.scale.setScalar(105) 10 | ball.name = BALL 11 | ball.castShadow = true 12 | scene.add(ball) 13 | return new BallManager(ball) 14 | } 15 | -------------------------------------------------------------------------------- /src/builders/field/addCameras.ts: -------------------------------------------------------------------------------- 1 | import { OrthographicCamera, PerspectiveCamera, Scene, Vector3 } from "three" 2 | 3 | import { DEFAULT_CAMERA_OPTIONS } from "../../constants/defaultCameraOptions" 4 | import { 5 | ABOVE_FIELD_CAMERA, 6 | BLUE_GOAL_CAMERA, 7 | FREE_CAMERA, 8 | ORANGE_GOAL_CAMERA, 9 | ORTHOGRAPHIC, 10 | } from "../../constants/gameObjectNames" 11 | 12 | export const addCameras = (scene: Scene) => { 13 | const blueGoalCamera = new PerspectiveCamera(...DEFAULT_CAMERA_OPTIONS) 14 | blueGoalCamera.name = BLUE_GOAL_CAMERA 15 | blueGoalCamera.position.set(0, 750, -5000) 16 | scene.add(blueGoalCamera) 17 | 18 | const orangeGoalCamera = new PerspectiveCamera(...DEFAULT_CAMERA_OPTIONS) 19 | orangeGoalCamera.name = ORANGE_GOAL_CAMERA 20 | orangeGoalCamera.position.set(0, 750, 5000) 21 | scene.add(orangeGoalCamera) 22 | 23 | const aboveFieldCamera = new PerspectiveCamera(...DEFAULT_CAMERA_OPTIONS) 24 | aboveFieldCamera.name = ABOVE_FIELD_CAMERA 25 | aboveFieldCamera.position.set(0, 2000, 0) 26 | scene.add(aboveFieldCamera) 27 | 28 | const freeCamera = new PerspectiveCamera(...DEFAULT_CAMERA_OPTIONS) 29 | freeCamera.name = FREE_CAMERA 30 | freeCamera.position.set(0, 1000, 0) 31 | scene.add(freeCamera) 32 | 33 | const generateOrthographicCamera = () => { 34 | const camera = new OrthographicCamera(-320, 320, 240, -240, 0.1, 20000) 35 | camera.zoom = 0.05 36 | scene.add(camera) 37 | return camera 38 | } 39 | 40 | const ORTHOGRAPHIC_X = 3500 41 | const ORTHOGRAPHIC_Y = 5000 42 | const ORTHOGRAPHIC_Z = 5000 43 | 44 | const orthographicCameras = [ 45 | { 46 | name: ORTHOGRAPHIC.BLUE_LEFT, 47 | position: new Vector3(ORTHOGRAPHIC_X, ORTHOGRAPHIC_Y, -ORTHOGRAPHIC_Z), 48 | }, 49 | { 50 | name: ORTHOGRAPHIC.BLUE_RIGHT, 51 | position: new Vector3(-ORTHOGRAPHIC_X, ORTHOGRAPHIC_Y, -ORTHOGRAPHIC_Z), 52 | }, 53 | { 54 | name: ORTHOGRAPHIC.ORANGE_LEFT, 55 | position: new Vector3(-ORTHOGRAPHIC_X, ORTHOGRAPHIC_Y, ORTHOGRAPHIC_Z), 56 | }, 57 | { 58 | name: ORTHOGRAPHIC.ORANGE_RIGHT, 59 | position: new Vector3(ORTHOGRAPHIC_X, ORTHOGRAPHIC_Y, ORTHOGRAPHIC_Z), 60 | }, 61 | { 62 | name: ORTHOGRAPHIC.ABOVE_FIELD, 63 | position: new Vector3(0, 8000, 0), 64 | }, 65 | ].map(({ name, position }) => { 66 | const camera = generateOrthographicCamera() 67 | camera.name = name 68 | camera.position.set(position.x, position.y, position.z) 69 | camera.lookAt(0, 0, 0) 70 | return camera 71 | }) 72 | 73 | return [ 74 | blueGoalCamera, 75 | orangeGoalCamera, 76 | aboveFieldCamera, 77 | freeCamera, 78 | ...orthographicCameras, 79 | ] 80 | } 81 | -------------------------------------------------------------------------------- /src/builders/field/buildPlayfield.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DoubleSide, 3 | Mesh, 4 | MeshPhongMaterial, 5 | PlaneBufferGeometry, 6 | Scene, 7 | } from "three" 8 | 9 | import GameFieldAssets from "../../loaders/scenes/GameFieldAssets" 10 | import FieldManager from "../../managers/models/FieldManager" 11 | import { addCameras } from "./addCameras" 12 | 13 | export const buildPlayfield = (scene: Scene) => { 14 | /** 15 | * Temporary 16 | */ 17 | const goalPlane = new PlaneBufferGeometry(2000, 1284.5, 1, 1) 18 | const blueGoalMaterial = new MeshPhongMaterial({ 19 | color: "#2196f3", 20 | side: DoubleSide, 21 | opacity: 0.3, 22 | transparent: true, 23 | }) 24 | const orangeGoalMaterial = new MeshPhongMaterial({ 25 | color: "#ff9800", 26 | side: DoubleSide, 27 | opacity: 0.3, 28 | transparent: true, 29 | }) 30 | const blueGoal = new Mesh(goalPlane, blueGoalMaterial) 31 | blueGoal.position.z = -5120 32 | scene.add(blueGoal) 33 | const orangeGoal = new Mesh(goalPlane, orangeGoalMaterial) 34 | orangeGoal.position.z = 5120 35 | orangeGoal.rotation.y = Math.PI 36 | scene.add(orangeGoal) 37 | /** 38 | * /Temporary 39 | */ 40 | 41 | const { field } = GameFieldAssets.getAssets() 42 | field.scale.setScalar(400) 43 | 44 | field.children.forEach(child => (child.receiveShadow = true)) 45 | field.receiveShadow = true 46 | scene.add(field) 47 | 48 | const cameras = addCameras(scene) 49 | return new FieldManager(field, cameras) 50 | } 51 | -------------------------------------------------------------------------------- /src/builders/player/BasicPlayerBuilder.ts: -------------------------------------------------------------------------------- 1 | import { Group, LoadingManager, Scene } from "three" 2 | 3 | import BasicCarAssets from "../../loaders/scenes/BasicCarAssets" 4 | import PlayerManager from "../../managers/models/PlayerManager" 5 | import { ExtendedPlayer } from "../../models/ReplayMetadata" 6 | import { getCarName, getGroupName } from "../utils/playerNameGetters" 7 | import { generateSprite } from "./generateSprite" 8 | import PlayerBuilder from "./PlayerBuilder" 9 | import { positionWheels } from "./positionWheels" 10 | 11 | export default class BasicPlayerBuilder implements PlayerBuilder { 12 | private readonly basicCarAssets: BasicCarAssets 13 | 14 | constructor(loadingManager?: LoadingManager) { 15 | this.basicCarAssets = new BasicCarAssets(loadingManager) 16 | } 17 | 18 | async buildPlayers( 19 | scene: Scene, 20 | playerInfo: ExtendedPlayer[] 21 | ): Promise { 22 | await this.basicCarAssets.load() 23 | return playerInfo.map((player) => this.buildCarGroup(scene, player)) 24 | } 25 | 26 | private buildCarGroup(scene: Scene, player: ExtendedPlayer): PlayerManager { 27 | const { orangeCar, blueCar, wheel } = this.basicCarAssets.getAssets() 28 | 29 | orangeCar.children.forEach((child) => { 30 | child.castShadow = true 31 | child.receiveShadow = true 32 | }) 33 | blueCar.children.forEach((child) => { 34 | child.castShadow = true 35 | child.receiveShadow = true 36 | }) 37 | wheel.children.forEach((child) => { 38 | child.castShadow = true 39 | child.receiveShadow = true 40 | }) 41 | 42 | // Build the car with its wheels (for rotation) 43 | const car = player.isOrange ? orangeCar.clone(true) : blueCar.clone(true) 44 | car.children.forEach((child) => (child.position.y += 31)) 45 | car.name = getCarName(player.name) 46 | car.add(positionWheels(wheel)) 47 | 48 | // Build sprite and camera container (for position) 49 | const group = new Group() 50 | group.name = getGroupName(player.name) 51 | group.add(car) 52 | group.add(generateSprite(player.name, player.isOrange)) 53 | 54 | scene.add(group) 55 | return new PlayerManager(player.name, player.isOrange, group) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/builders/player/PlayerBuilder.d.ts: -------------------------------------------------------------------------------- 1 | import { Scene } from "three" 2 | 3 | import PlayerManager from "../../managers/models/PlayerManager" 4 | import { ExtendedPlayer } from "../../models/ReplayMetadata" 5 | 6 | export default interface PlayerBuilder { 7 | buildPlayers( 8 | scene: Scene, 9 | playerInfo: ExtendedPlayer[] 10 | ): Promise 11 | } 12 | -------------------------------------------------------------------------------- /src/builders/player/generateSprite.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LinearMipMapLinearFilter, 3 | NearestFilter, 4 | Sprite, 5 | SpriteMaterial, 6 | Texture, 7 | } from "three" 8 | 9 | import { SPRITE } from "../../constants/gameObjectNames" 10 | 11 | export const SPRITE_ORTHO_SCALE = 2400 12 | 13 | export const generateSprite = (playerName: string, orangeTeam: boolean) => { 14 | const name = playerName.toUpperCase() 15 | 16 | const border = 10 17 | const fontSize = 60 18 | const canvasSize = 480 19 | const canvas = document.createElement("canvas") 20 | canvas.width = 512 21 | canvas.height = canvas.width 22 | const context = canvas.getContext("2d") 23 | 24 | const roundRect = ( 25 | ct: CanvasRenderingContext2D, 26 | x: number, 27 | y: number, 28 | w: number, 29 | h: number, 30 | radius: number 31 | ) => { 32 | if (w > h) { 33 | radius = h / 2 34 | } else { 35 | radius = w / 2 36 | } 37 | ct.beginPath() 38 | ct.moveTo(x + radius, y) 39 | ct.arcTo(x + w, y, x + w, y + h, radius) 40 | ct.arcTo(x + w, y + h, x, y + h, radius) 41 | ct.arcTo(x, y + h, x, y, radius) 42 | ct.arcTo(x, y, x + w, y, radius) 43 | ct.closePath() 44 | return ct 45 | } 46 | 47 | if (context) { 48 | const tagYOffset = 70 49 | const tagY = (canvas.height / 2) - (fontSize + border * 2) - tagYOffset 50 | context.font = `bold ${fontSize}px Arial` 51 | context.textBaseline = "middle" 52 | context.fillStyle = orangeTeam ? "#ff9800" : "#2196f3" 53 | roundRect( 54 | context, 55 | border, 56 | tagY, 57 | canvasSize, 58 | fontSize + border * 2, 59 | fontSize * 2 60 | ).fill() 61 | context.strokeStyle = "#eee" 62 | context.lineWidth = border 63 | roundRect( 64 | context, 65 | border, 66 | tagY, 67 | canvasSize, 68 | fontSize + border * 2, 69 | fontSize * 2 70 | ).stroke() 71 | context.fillStyle = "#fff" 72 | const measure = context.measureText(name) 73 | const padding = border / 2 + fontSize / 2 74 | const maxWidth = canvasSize - padding * 2 75 | const width = maxWidth > measure.width ? measure.width : maxWidth 76 | const x = canvasSize / 2 + border / 2 - width / 2 77 | context.fillText(name, x, canvas.height / 2 - fontSize / 2 - border - tagYOffset, maxWidth) 78 | } 79 | 80 | const texture = new Texture(canvas) 81 | texture.needsUpdate = true 82 | texture.magFilter = NearestFilter 83 | texture.minFilter = LinearMipMapLinearFilter 84 | const spriteMaterial = new SpriteMaterial({ 85 | map: texture, 86 | }) 87 | const sprite = new Sprite(spriteMaterial) 88 | sprite.position.setY(40) 89 | sprite.name = SPRITE 90 | 91 | return sprite 92 | } 93 | -------------------------------------------------------------------------------- /src/builders/player/positionWheels.ts: -------------------------------------------------------------------------------- 1 | import { Group, Object3D } from "three" 2 | 3 | export const positionWheels = (wheelObject: Object3D) => { 4 | const LEFT_DISTANCE = 55 5 | const FORWARD_DISTANCE = 80 6 | const VERTICAL_DISTANCE = 0 7 | const wheelGroup = new Group() 8 | const frontLeft = wheelObject.clone() 9 | frontLeft.name = "Front Left" 10 | frontLeft.position.set(FORWARD_DISTANCE, VERTICAL_DISTANCE, -LEFT_DISTANCE) 11 | const frontRight = wheelObject.clone() 12 | frontRight.name = "Front Right" 13 | frontRight.position.set(FORWARD_DISTANCE, VERTICAL_DISTANCE, LEFT_DISTANCE) 14 | frontRight.scale.z = -1 15 | const backLeft = wheelObject.clone() 16 | backLeft.name = "Back Left" 17 | backLeft.position.set(-FORWARD_DISTANCE, VERTICAL_DISTANCE, -LEFT_DISTANCE) 18 | const backRight = wheelObject.clone() 19 | backRight.name = "Back Right" 20 | backRight.position.set(-FORWARD_DISTANCE, VERTICAL_DISTANCE, LEFT_DISTANCE) 21 | backRight.scale.z = -1 22 | 23 | wheelGroup.add(frontLeft, frontRight, backLeft, backRight) 24 | return wheelGroup 25 | } 26 | -------------------------------------------------------------------------------- /src/builders/scene/addLighting.ts: -------------------------------------------------------------------------------- 1 | import { AmbientLight, DirectionalLight, HemisphereLight, Scene } from "three" 2 | 3 | export const addLighting = (scene: Scene) => { 4 | // Ambient light provides uniform lighting to all objects in the scene 5 | const ambientLight = new AmbientLight(0xffffff, 0.2) 6 | scene.add(ambientLight) 7 | 8 | // Hemisphere light gives the cars their shine and color 9 | const hemisphereLight = new HemisphereLight(0xffffbb, 0xffffff, 0.5) 10 | scene.add(hemisphereLight) 11 | 12 | // The directional light is purely responsible for casting shadows 13 | const dirLight = new DirectionalLight(0xffffff, 1.5) 14 | dirLight.color.setHSL(0.1, 1, 0.95) 15 | dirLight.position.set(0, 2300, 0) 16 | dirLight.castShadow = true 17 | 18 | // Denotes the "detail" of the shadow 19 | dirLight.shadow.mapSize.width = 512 20 | dirLight.shadow.mapSize.height = 512 21 | 22 | // If you were "looking down" on the field, this is the area the light covers 23 | const distance = 5120 24 | dirLight.shadow.camera.left = -distance 25 | dirLight.shadow.camera.right = distance 26 | dirLight.shadow.camera.top = distance 27 | dirLight.shadow.camera.bottom = -distance 28 | 29 | dirLight.shadow.camera.far = 3500 30 | dirLight.shadow.bias = -0.0001 31 | dirLight.shadow.radius = 3 32 | 33 | scene.add(dirLight) 34 | } 35 | -------------------------------------------------------------------------------- /src/builders/utils/animationNameGetters.ts: -------------------------------------------------------------------------------- 1 | const getName = (objectName: string, suffix: string) => { 2 | return `${objectName}${suffix}` 3 | } 4 | 5 | export const getActionClipName = (objectName: string) => { 6 | return getName(objectName, "AnimationClip") 7 | } 8 | 9 | export const getPositionName = (objectName: string) => { 10 | return getName(objectName, ".position") 11 | } 12 | 13 | export const getRotationName = (objectName: string) => { 14 | return getName(objectName, ".quaternion") 15 | } 16 | -------------------------------------------------------------------------------- /src/builders/utils/playerNameGetters.ts: -------------------------------------------------------------------------------- 1 | import { CAR_SUFFIX, GROUP_SUFFIX } from "../../constants/gameObjectNames" 2 | import { hashCode } from "../../utils/hashCode" 3 | 4 | export const getCarName = (playerName: string) => { 5 | return `${hashCode(playerName)}${CAR_SUFFIX}` 6 | } 7 | 8 | export const getGroupName = (playerName: string) => { 9 | return `${hashCode(playerName)}${GROUP_SUFFIX}` 10 | } 11 | -------------------------------------------------------------------------------- /src/constants/defaultCameraOptions.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_CAMERA_OPTIONS = [80, 2, 0.1, 20000] 2 | -------------------------------------------------------------------------------- /src/constants/eventNames.ts: -------------------------------------------------------------------------------- 1 | export const CAMERA_CHANGE = "CAMERA_CHANGE" 2 | export const CAMERA_FRAME_UPDATE = "CAMERA_FRAME_UPDATE" 3 | export const PLAY_PAUSE = "PLAY_PAUSE" 4 | export const FRAME = "FRAME" 5 | export const CANVAS_RESIZE = "CANVAS_RESIZE" 6 | export const KEY_CONTROL = "KEY_CONTROL" 7 | -------------------------------------------------------------------------------- /src/constants/gameObjectNames.ts: -------------------------------------------------------------------------------- 1 | export const BALL = "ball" 2 | export const SPRITE = "sprite" 3 | export const CAR_SUFFIX = "-car" 4 | export const GROUP_SUFFIX = "-group" 5 | 6 | export const BLUE_GOAL_CAMERA = "Blue Goal Camera" 7 | export const ORANGE_GOAL_CAMERA = "Orange Goal Camera" 8 | export const ABOVE_FIELD_CAMERA = "Above Field Camera" 9 | export const FREE_CAMERA = "Free Camera" 10 | export const ORTHOGRAPHIC = { 11 | ABOVE_FIELD: "ORTHOGRAPHIC_ABOVE_FIELD", 12 | BLUE_LEFT: "ORTHOGRAPHIC_BLUE_LEFT", 13 | BLUE_RIGHT: "ORTHOGRAPHIC_BLUE_RIGHT", 14 | ORANGE_LEFT: "ORTHOGRAPHIC_ORANGE_LEFT", 15 | ORANGE_RIGHT: "ORTHOGRAPHIC_ORANGE_RIGHT", 16 | } 17 | -------------------------------------------------------------------------------- /src/eventbus/EventBus.ts: -------------------------------------------------------------------------------- 1 | interface Listener { 2 | type: string 3 | callback: (args: T) => void 4 | scope?: any 5 | } 6 | 7 | type Callback = (arg: A) => void 8 | 9 | class EventBus { 10 | private readonly listeners: { [key: string]: Listener[] } 11 | 12 | constructor() { 13 | this.listeners = {} 14 | } 15 | 16 | buildEvent(type: string, scope?: any) { 17 | const addEventListener = (callback: Callback) => 18 | this.addEventListener(type, callback, scope) 19 | const removeEventListener = (callback: Callback) => 20 | this.removeEventListener(type, callback, scope) 21 | const dispatch = (args: EventBusArg) => this.dispatch(type, args) 22 | 23 | return { 24 | addEventListener, 25 | removeEventListener, 26 | dispatch, 27 | } 28 | } 29 | 30 | addEventListener(type: string, callback: Callback, scope?: any) { 31 | if (!this.listeners[type]) { 32 | this.listeners[type] = [] 33 | } 34 | // Remove duplicate event listeners 35 | this.removeEventListener(type, callback, scope) 36 | // Add this listener to the list 37 | this.listeners[type].push({ type, callback, scope }) 38 | } 39 | 40 | removeEventListener(type: string, callback: Callback, scope?: any) { 41 | if (!this.listeners[type]) { 42 | return 43 | } 44 | this.listeners[type] = this.listeners[type].filter( 45 | ({ type: t, callback: c, scope: s }) => 46 | !(type === t && callback === c && scope === s) 47 | ) 48 | } 49 | 50 | dispatch(type: string, arg: A) { 51 | if (!this.listeners[type]) { 52 | return 53 | } 54 | this.listeners[type].forEach(listener => { 55 | if (type === listener.type) { 56 | listener.callback.call(listener.scope || null, arg) 57 | } 58 | }) 59 | } 60 | 61 | reset() { 62 | Object.keys(this.listeners).forEach(key => { 63 | delete this.listeners[key] 64 | }) 65 | } 66 | } 67 | 68 | export default new EventBus() 69 | -------------------------------------------------------------------------------- /src/eventbus/events/cameraChange.ts: -------------------------------------------------------------------------------- 1 | import { Camera } from "three" 2 | 3 | import { CAMERA_CHANGE } from "../../constants/eventNames" 4 | import EventBus from "../EventBus" 5 | 6 | /** 7 | * Event fired when the active camera is updated with a new object. 8 | */ 9 | export interface CameraChangeEvent { 10 | camera: Camera 11 | } 12 | 13 | export const { 14 | addEventListener: addCameraChangeListener, 15 | removeEventListener: removeCameraChangeListener, 16 | dispatch: dispatchCameraChange, 17 | } = EventBus.buildEvent(CAMERA_CHANGE) 18 | -------------------------------------------------------------------------------- /src/eventbus/events/cameraFrameUpdate.ts: -------------------------------------------------------------------------------- 1 | import { Camera, Vector3 } from "three" 2 | 3 | import { CAMERA_FRAME_UPDATE } from "../../constants/eventNames" 4 | import EventBus from "../EventBus" 5 | 6 | /** 7 | * Event that fires telling all cameras to adjust their settings. 8 | */ 9 | export interface CameraFrameUpdateEvent { 10 | ballPosition: Vector3 11 | ballCam: boolean 12 | isUsingBoost: boolean 13 | activeCamera: Camera 14 | } 15 | 16 | export const { 17 | addEventListener: addCameraFrameUpdateListener, 18 | removeEventListener: removeCameraFrameUpdateListener, 19 | dispatch: dispatchCameraFrameUpdate, 20 | } = EventBus.buildEvent(CAMERA_FRAME_UPDATE) 21 | -------------------------------------------------------------------------------- /src/eventbus/events/canvasResize.ts: -------------------------------------------------------------------------------- 1 | import { CANVAS_RESIZE } from "../../constants/eventNames" 2 | import EventBus from "../EventBus" 3 | 4 | /** 5 | * Fires when the main canvas is resized and its dimensions are adjusted. 6 | */ 7 | export interface CanvasResizeEvent { 8 | width: number 9 | height: number 10 | } 11 | 12 | export const { 13 | addEventListener: addCanvasResizeListener, 14 | removeEventListener: removeCanvasResizeListener, 15 | dispatch: dispatchCanvasResizeEvent, 16 | } = EventBus.buildEvent(CANVAS_RESIZE) 17 | -------------------------------------------------------------------------------- /src/eventbus/events/frame.ts: -------------------------------------------------------------------------------- 1 | import { FRAME } from "../../constants/eventNames" 2 | import EventBus from "../EventBus" 3 | 4 | /** 5 | * Fires each time the global game clock advances a frame or updates its current frame. 6 | */ 7 | export interface FrameEvent { 8 | delta: number 9 | frame: number 10 | elapsedTime: number 11 | } 12 | 13 | export const { 14 | addEventListener: addFrameListener, 15 | removeEventListener: removeFrameListener, 16 | dispatch: dispatchFrameEvent, 17 | } = EventBus.buildEvent(FRAME) 18 | -------------------------------------------------------------------------------- /src/eventbus/events/keyControl.ts: -------------------------------------------------------------------------------- 1 | import { Vector3 } from "three" 2 | 3 | import { KEY_CONTROL } from "../../constants/eventNames" 4 | import EventBus from "../EventBus" 5 | 6 | type Direction = "forward" | "backward" | "left" | "right" | "up" | "down" 7 | 8 | /** 9 | * Fires when the keys are pressed in a certain manner. 10 | */ 11 | export interface KeyControlEvent { 12 | directions: Direction[] 13 | speed: boolean 14 | } 15 | 16 | export const { 17 | addEventListener: addKeyControlListener, 18 | removeEventListener: removeKeyControlListener, 19 | dispatch: dispatchKeyControlEvent, 20 | } = EventBus.buildEvent(KEY_CONTROL) 21 | 22 | export const applyDirections = ( 23 | cameraDirection: Vector3, 24 | directions: Direction[], 25 | multiplier: number 26 | ) => { 27 | const newVector = new Vector3() 28 | directions.forEach(direction => { 29 | const localCameraDirection = new Vector3() 30 | localCameraDirection.copy(cameraDirection) 31 | switch (direction) { 32 | case "forward": 33 | newVector.add(localCameraDirection.multiplyScalar(multiplier)) 34 | break 35 | case "backward": 36 | newVector.sub(localCameraDirection.multiplyScalar(multiplier)) 37 | break 38 | case "up": 39 | newVector.add(new Vector3(0, multiplier, 0)) 40 | break 41 | case "down": 42 | newVector.add(new Vector3(0, -multiplier, 0)) 43 | break 44 | case "left": 45 | localCameraDirection.cross(new Vector3(0, -1, 0)) 46 | newVector.add(localCameraDirection.multiplyScalar(multiplier)) 47 | break 48 | case "right": 49 | localCameraDirection.cross(new Vector3(0, 1, 0)) 50 | newVector.add(localCameraDirection.multiplyScalar(multiplier)) 51 | break 52 | } 53 | }) 54 | return newVector 55 | } 56 | -------------------------------------------------------------------------------- /src/eventbus/events/playPause.ts: -------------------------------------------------------------------------------- 1 | import { PLAY_PAUSE } from "../../constants/eventNames" 2 | import EventBus from "../EventBus" 3 | 4 | /** 5 | * Fires when the global game clock has paused. 6 | */ 7 | export interface PlayPauseEvent { 8 | paused: boolean 9 | } 10 | 11 | export const { 12 | addEventListener: addPlayPauseListener, 13 | removeEventListener: removePlayPauseListener, 14 | dispatch: dispatchPlayPauseEvent, 15 | } = EventBus.buildEvent(PLAY_PAUSE) 16 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // Managers 2 | export { GameManager } from "./managers/GameManager" 3 | 4 | // Loaders 5 | export { loadReplay } from "./viewer/clients/loadReplay" 6 | export { loadBuilderFromReplay } from "./viewer/clients/loadBuilderFromReplay" 7 | 8 | // Components 9 | export { default as ReplayViewer } from "./viewer/components/ReplayViewer" 10 | export { 11 | default as GameManagerLoader, 12 | } from "./viewer/components/GameManagerLoader" 13 | export { 14 | default as CompactPlayControls, 15 | } from "./viewer/components/CompactPlayControls" 16 | export { default as PlayControls } from "./viewer/components/PlayControls" 17 | export { 18 | default as PlayerCameraControls, 19 | } from "./viewer/components/PlayerCameraControls" 20 | export { 21 | default as FieldCameraControls, 22 | } from "./viewer/components/FieldCameraControls" 23 | export { default as Slider } from "./viewer/components/Slider" 24 | export { default as DrawingControls } from "./viewer/components/DrawingControls" 25 | 26 | // Utilities 27 | export { default as FPSClock } from "./utils/FPSClock" 28 | 29 | // Types 30 | export { GameBuilderOptions } from "./builders/GameBuilder" 31 | export { ReplayData } from "./models/ReplayData" 32 | export { ReplayMetadata } from "./models/ReplayMetadata" 33 | export { CameraLocationOptions } from "./managers/CameraManager" 34 | -------------------------------------------------------------------------------- /src/loaders/helpers/Panel.ts: -------------------------------------------------------------------------------- 1 | const round = Math.round 2 | 3 | type FillStyle = string | CanvasGradient | CanvasPattern 4 | 5 | export class Panel { 6 | dom: HTMLCanvasElement 7 | update: (value: number, maxValue: number) => void 8 | 9 | private min: number 10 | private max: number 11 | private readonly name: string 12 | private readonly fg: FillStyle 13 | private readonly bg: FillStyle 14 | 15 | constructor(name: string, fg: FillStyle, bg: FillStyle) { 16 | this.min = Infinity 17 | this.max = 0 18 | this.name = name 19 | this.fg = fg 20 | this.bg = bg 21 | const { canvas, update } = this.init() 22 | this.dom = canvas 23 | this.update = update 24 | } 25 | 26 | private init() { 27 | const PR = round(window.devicePixelRatio || 1) 28 | 29 | const WIDTH = 80 * PR 30 | const HEIGHT = 48 * PR 31 | const TEXT_X = 3 * PR 32 | const TEXT_Y = 2 * PR 33 | const GRAPH_X = 3 * PR 34 | const GRAPH_Y = 15 * PR 35 | const GRAPH_WIDTH = 74 * PR 36 | const GRAPH_HEIGHT = 30 * PR 37 | 38 | const canvas = document.createElement("canvas") 39 | canvas.width = WIDTH 40 | canvas.height = HEIGHT 41 | canvas.style.cssText = "width:80px;height:48px" 42 | 43 | const context = canvas.getContext("2d")! 44 | context.font = "bold " + 9 * PR + "px Helvetica,Arial,sans-serif" 45 | context.textBaseline = "top" 46 | 47 | context.fillStyle = this.bg 48 | context.fillRect(0, 0, WIDTH, HEIGHT) 49 | 50 | context.fillStyle = this.fg 51 | context.fillText(this.name, TEXT_X, TEXT_Y) 52 | context.fillRect(GRAPH_X, GRAPH_Y, GRAPH_WIDTH, GRAPH_HEIGHT) 53 | 54 | context.fillStyle = this.bg 55 | context.globalAlpha = 0.9 56 | context.fillRect(GRAPH_X, GRAPH_Y, GRAPH_WIDTH, GRAPH_HEIGHT) 57 | 58 | const update = (value: number, maxValue: number) => { 59 | this.min = Math.min(this.min, value) 60 | this.max = Math.max(this.max, value) 61 | 62 | context.fillStyle = this.bg 63 | context.globalAlpha = 1 64 | context.fillRect(0, 0, WIDTH, GRAPH_Y) 65 | context.fillStyle = this.fg 66 | context.fillText( 67 | `${round(value)} ${this.name} (${round(this.min)}-${round(this.max)})`, 68 | TEXT_X, 69 | TEXT_Y 70 | ) 71 | 72 | context.drawImage( 73 | canvas, 74 | GRAPH_X + PR, 75 | GRAPH_Y, 76 | GRAPH_WIDTH - PR, 77 | GRAPH_HEIGHT, 78 | GRAPH_X, 79 | GRAPH_Y, 80 | GRAPH_WIDTH - PR, 81 | GRAPH_HEIGHT 82 | ) 83 | 84 | context.fillRect(GRAPH_X + GRAPH_WIDTH - PR, GRAPH_Y, PR, GRAPH_HEIGHT) 85 | 86 | context.fillStyle = this.bg 87 | context.globalAlpha = 0.9 88 | context.fillRect( 89 | GRAPH_X + GRAPH_WIDTH - PR, 90 | GRAPH_Y, 91 | PR, 92 | round((1 - value / maxValue) * GRAPH_HEIGHT) 93 | ) 94 | } 95 | 96 | return { canvas, update } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/loaders/helpers/Stats.ts: -------------------------------------------------------------------------------- 1 | import { Panel } from "./Panel" 2 | 3 | export class Stats { 4 | dom: HTMLDivElement 5 | 6 | private mode: number 7 | 8 | private beginTime: number 9 | private prevTime: number 10 | private frames: number 11 | 12 | private readonly fpsPanel: Panel 13 | private readonly msPanel: Panel 14 | private readonly memPanel?: Panel 15 | 16 | constructor() { 17 | this.mode = 0 18 | 19 | const scope = this 20 | this.dom = document.createElement("div") 21 | scope.dom.style.cssText = 22 | "position:absolute;top:-48px;left:0;cursor:pointer;opacity:0.9;z-index:10000" 23 | scope.dom.addEventListener( 24 | "click", 25 | event => { 26 | event.preventDefault() 27 | scope.showPanel(++scope.mode % scope.dom.children.length) 28 | }, 29 | false 30 | ) 31 | 32 | this.beginTime = (performance || Date).now() 33 | this.prevTime = this.beginTime 34 | this.frames = 0 35 | 36 | this.fpsPanel = this.addPanel(new Panel("FPS", "#0ff", "#002")) 37 | this.msPanel = this.addPanel(new Panel("MS", "#0f0", "#020")) 38 | 39 | if (self.performance && (self.performance as any).memory) { 40 | this.memPanel = this.addPanel(new Panel("MB", "#f08", "#201")) 41 | } 42 | 43 | this.showPanel(0) 44 | } 45 | 46 | addPanel(panel: any) { 47 | this.dom.appendChild(panel.dom) 48 | return panel 49 | } 50 | 51 | /** 52 | * 0: fps, 1: ms, 2: mb, 3+: custom 53 | * @param id number referenced above 54 | */ 55 | showPanel(id: number) { 56 | for (let i = 0; i < this.dom.children.length; i++) { 57 | const child = this.dom.children[i] as any 58 | if (child.style) { 59 | child.style.display = i === id ? "block" : "none" 60 | } 61 | } 62 | 63 | this.mode = id 64 | } 65 | 66 | begin() { 67 | this.beginTime = (performance || Date).now() 68 | } 69 | 70 | end() { 71 | this.frames++ 72 | 73 | const time = (performance || Date).now() 74 | 75 | this.msPanel.update(time - this.beginTime, 200) 76 | 77 | if (time > this.prevTime + 1000) { 78 | this.fpsPanel.update((this.frames * 1000) / (time - this.prevTime), 100) 79 | 80 | this.prevTime = time 81 | this.frames = 0 82 | 83 | if (this.memPanel) { 84 | const memory = (performance as any).memory 85 | this.memPanel.update( 86 | memory.usedJSHeapSize / 1048576, 87 | memory.jsHeapSizeLimit / 1048576 88 | ) 89 | } 90 | } 91 | 92 | return time 93 | } 94 | update() { 95 | this.beginTime = this.end() 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/loaders/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Stats" 2 | -------------------------------------------------------------------------------- /src/loaders/operators/getChildByName.ts: -------------------------------------------------------------------------------- 1 | import { GLTF } from "three/examples/jsm/loaders/GLTFLoader" 2 | 3 | export const getChildByName = (asset: GLTF, name: string) => 4 | asset.scene.children.find(object => object.name === name) 5 | -------------------------------------------------------------------------------- /src/loaders/operators/loadMaterial.ts: -------------------------------------------------------------------------------- 1 | import { LoadingManager } from "three" 2 | import { MTLLoader } from "three/examples/jsm/loaders/MTLLoader" 3 | 4 | export const loadMaterial = (path: string, loadingManager?: LoadingManager) => { 5 | return new Promise( 6 | ( 7 | resolve: (material: MTLLoader.MaterialCreator) => void, 8 | reject: (err: Error | ErrorEvent) => void 9 | ) => { 10 | const mtlLoader = new MTLLoader(loadingManager) 11 | mtlLoader.load( 12 | path, 13 | (materialCreator: MTLLoader.MaterialCreator) => { 14 | resolve(materialCreator) 15 | }, 16 | undefined, 17 | (error: Error | ErrorEvent) => { 18 | reject(error) 19 | } 20 | ) 21 | } 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /src/loaders/operators/loadObject.ts: -------------------------------------------------------------------------------- 1 | import { LoadingManager } from "three" 2 | import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader" 3 | import { GLTF, GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader" 4 | 5 | export const loadObject = (path: string, loadingManager?: LoadingManager) => { 6 | return new Promise( 7 | ( 8 | resolve: (gltf: GLTF) => void, 9 | reject: (err: Error | ErrorEvent) => void 10 | ) => { 11 | const gltfLoader = new GLTFLoader(loadingManager) 12 | const dracoLoader = new DRACOLoader() 13 | dracoLoader.setDecoderPath("https://www.gstatic.com/draco/versioned/decoders/1.4.1/") 14 | gltfLoader.setDRACOLoader(dracoLoader) 15 | gltfLoader.load( 16 | path, 17 | (gltf: GLTF) => { 18 | resolve(gltf) 19 | }, 20 | undefined, 21 | (error: Error | ErrorEvent) => { 22 | reject(error) 23 | } 24 | ) 25 | } 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/loaders/operators/throwLoadingError.ts: -------------------------------------------------------------------------------- 1 | class MissingAssetError extends Error { 2 | constructor(message: string) { 3 | super(message) 4 | this.name = "MissingAssetError" 5 | } 6 | } 7 | 8 | export const throwLoadingError = (assetName: string) => { 9 | throw new MissingAssetError(`Unable to load ${assetName} asset`) 10 | } 11 | -------------------------------------------------------------------------------- /src/loaders/scenes/BasicCarAssets.ts: -------------------------------------------------------------------------------- 1 | import { Group, LoadingManager, Object3D } from "three" 2 | 3 | import { loadBlueCar, loadOrangeCar, loadWheel } from "../storage/loadCar" 4 | 5 | interface AvailableAssets { 6 | orangeCar: Group 7 | blueCar: Group 8 | wheel: Object3D 9 | } 10 | 11 | export default class BasicCarAssets { 12 | loadingManager: LoadingManager 13 | assets?: AvailableAssets 14 | 15 | constructor(loadingManager?: LoadingManager) { 16 | this.loadingManager = loadingManager || new LoadingManager() 17 | } 18 | 19 | async load() { 20 | const lm = this.loadingManager 21 | return Promise.all([ 22 | loadOrangeCar(lm), 23 | loadBlueCar(lm), 24 | loadWheel(lm), 25 | ]).then(([orangeCar, blueCar, wheel]) => { 26 | this.assets = { 27 | orangeCar, 28 | blueCar, 29 | wheel, 30 | } 31 | }) 32 | } 33 | 34 | getAssets() { 35 | if (!this.assets) { 36 | throw new Error("Must call `load` before using assets for this scene") 37 | } 38 | return this.assets 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/loaders/scenes/GameFieldAssets.ts: -------------------------------------------------------------------------------- 1 | import { Group, LoadingManager, Object3D } from "three" 2 | 3 | import { loadBall } from "../storage/loadBall" 4 | import { loadField } from "../storage/loadField" 5 | 6 | interface AvailableAssets { 7 | ball: Object3D 8 | field: Group 9 | } 10 | 11 | class GameFieldAssets { 12 | loadingManager: LoadingManager 13 | assets?: AvailableAssets 14 | 15 | constructor() { 16 | this.loadingManager = new LoadingManager() 17 | } 18 | 19 | async load() { 20 | const lm = this.loadingManager 21 | return Promise.all([ 22 | loadBall(lm), 23 | loadField(lm) 24 | ]).then(([ball, field]) => { 25 | this.assets = { 26 | ball, 27 | field 28 | } as AvailableAssets 29 | }) 30 | } 31 | 32 | getAssets() { 33 | if (!this.assets) { 34 | throw new Error("Must call `load` before using assets for this scene") 35 | } 36 | return this.assets 37 | } 38 | } 39 | 40 | export default new GameFieldAssets() 41 | -------------------------------------------------------------------------------- /src/loaders/storage/loadBall.ts: -------------------------------------------------------------------------------- 1 | import { LoadingManager, Object3D } from "three" 2 | 3 | import { getChildByName } from "../operators/getChildByName" 4 | import { loadObject } from "../operators/loadObject" 5 | import { throwLoadingError } from "../operators/throwLoadingError" 6 | import { storageMemoize } from "./storageMemoize" 7 | 8 | export const loadBall = (loadingManager?: LoadingManager) => 9 | storageMemoize(async () => { 10 | const { default: glb } = await import( 11 | // @ts-ignore 12 | /* webpackChunkName: "Ball" */ "../../assets/models/draco/Ball.glb" 13 | ) 14 | const ballGLTF = await loadObject(glb, loadingManager) 15 | const ball = getChildByName(ballGLTF, "Ball") 16 | if (!ball) { 17 | throwLoadingError("Ball") 18 | } 19 | return ball as Object3D 20 | }, "BALL") 21 | -------------------------------------------------------------------------------- /src/loaders/storage/loadCar.ts: -------------------------------------------------------------------------------- 1 | import { Group, LoadingManager } from "three" 2 | 3 | import { getChildByName } from "../operators/getChildByName" 4 | import { loadObject } from "../operators/loadObject" 5 | import { throwLoadingError } from "../operators/throwLoadingError" 6 | import { storageMemoize } from "./storageMemoize" 7 | 8 | export const loadWheel = (loadingManager?: LoadingManager) => 9 | storageMemoize(async () => { 10 | const { default: glb } = await import( 11 | // @ts-ignore 12 | /* webpackChunkName: "Wheel" */ "../../assets/models/draco/Wheel.glb" 13 | ) 14 | const wheelGLTF = await loadObject(glb, loadingManager) 15 | const wheel = new Group() 16 | wheel.name = "Wheel" 17 | wheel.add(...wheelGLTF.scene.children) 18 | return wheel 19 | }, "WHEEL") 20 | 21 | export const loadOrangeCar = (loadingManager?: LoadingManager) => 22 | storageMemoize(async () => { 23 | const { default: glb } = await import( 24 | // @ts-ignore 25 | /* webpackChunkName: "Octane_ZXR_Orange" */ "../../assets/models/draco/Octane_ZXR_Orange.glb" 26 | ) 27 | const carGLTF = await loadObject(glb, loadingManager) 28 | const car = getChildByName(carGLTF, "Octane") 29 | if (!car) { 30 | throwLoadingError("Octane") 31 | } 32 | return car as Group 33 | }, "Octane_ZXR_Orange") 34 | 35 | export const loadBlueCar = (loadingManager?: LoadingManager) => 36 | storageMemoize(async () => { 37 | const { default: glb } = await import( 38 | // @ts-ignore 39 | /* webpackChunkName: "Octane_ZXR_Blue" */ "../../assets/models/draco/Octane_ZXR_Blue.glb" 40 | ) 41 | const carGLTF = await loadObject(glb, loadingManager) 42 | const car = getChildByName(carGLTF, "Octane") 43 | if (!car) { 44 | throwLoadingError("Octane") 45 | } 46 | return car as Group 47 | }, "Octane_ZXR_Blue") 48 | -------------------------------------------------------------------------------- /src/loaders/storage/loadField.ts: -------------------------------------------------------------------------------- 1 | import { Group, LoadingManager } from "three" 2 | 3 | import { loadObject } from "../operators/loadObject" 4 | import { storageMemoize } from "./storageMemoize" 5 | 6 | export const loadField = (loadingManager?: LoadingManager) => 7 | storageMemoize(async () => { 8 | const { default: glb } = await import( 9 | // @ts-ignore 10 | /* webpackChunkName: "Field" */ "../../assets/models/draco/Field.glb" 11 | ) 12 | const fieldGLTF = await loadObject(glb, loadingManager) 13 | const field = new Group() 14 | field.name = "Field" 15 | field.add(...fieldGLTF.scene.children) 16 | return field 17 | }, "FIELD") 18 | -------------------------------------------------------------------------------- /src/loaders/storage/storageMemoize.ts: -------------------------------------------------------------------------------- 1 | const STORAGE: { [key: string]: any } = {} 2 | 3 | export const storageMemoize = async ( 4 | loaderFunction: () => Promise, 5 | objectName: string 6 | ) => { 7 | if (STORAGE[objectName]) { 8 | return Promise.resolve(STORAGE[objectName]) 9 | } 10 | return loaderFunction().then(value => { 11 | STORAGE[objectName] = value 12 | return value 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /src/managers/AnimationManager.ts: -------------------------------------------------------------------------------- 1 | import { AnimationAction, AnimationClip, AnimationMixer } from "three" 2 | 3 | interface AnimationManagerOptions { 4 | playerClips: AnimationClip[] 5 | playerMixers: AnimationMixer[] 6 | ballClip: AnimationClip 7 | ballMixer: AnimationMixer 8 | } 9 | 10 | interface AnimationMixers { 11 | players: AnimationMixer[] 12 | ball: AnimationMixer 13 | } 14 | 15 | interface AnimationActions { 16 | players: AnimationAction[] 17 | ball: AnimationAction 18 | } 19 | 20 | export default class AnimationManager { 21 | private readonly mixers: AnimationMixers 22 | private readonly actions: AnimationActions 23 | 24 | private constructor({ 25 | playerClips, 26 | playerMixers, 27 | ballClip, 28 | ballMixer, 29 | }: AnimationManagerOptions) { 30 | this.actions = {} as any 31 | 32 | this.mixers = { 33 | players: playerMixers, 34 | ball: ballMixer, 35 | } 36 | 37 | // Build the player actions 38 | this.actions.players = [] 39 | for (let player = 0; player < playerClips.length; player++) { 40 | const clip = playerClips[player] 41 | const mixer = this.mixers.players[player] 42 | this.actions.players[player] = mixer.clipAction(clip) 43 | } 44 | // Build the ball action 45 | this.actions.ball = this.mixers.ball.clipAction(ballClip) 46 | } 47 | 48 | public playAnimationClips() { 49 | const { players, ball } = this.actions 50 | players.forEach(action => action.play()) 51 | ball.play() 52 | } 53 | 54 | public updateAnimationClips(delta: number) { 55 | const { players, ball } = this.mixers 56 | players.forEach(player => player.update(delta)) 57 | ball.update(delta) 58 | } 59 | 60 | /** 61 | * ======================================== 62 | * Managers are singletons 63 | * ======================================== 64 | */ 65 | private static instance?: AnimationManager 66 | static getInstance() { 67 | if (!AnimationManager.instance) { 68 | throw new Error("AnimationManager not initialized with call to `init`") 69 | } 70 | return AnimationManager.instance 71 | } 72 | static init(options: AnimationManagerOptions) { 73 | AnimationManager.instance = new AnimationManager(options) 74 | return AnimationManager.instance 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/managers/CameraManager.ts: -------------------------------------------------------------------------------- 1 | import { Camera, OrthographicCamera, PerspectiveCamera, Vector3 } from "three" 2 | 3 | import { 4 | ABOVE_FIELD_CAMERA, 5 | BLUE_GOAL_CAMERA, 6 | FREE_CAMERA, 7 | ORANGE_GOAL_CAMERA, 8 | ORTHOGRAPHIC, 9 | } from "../constants/gameObjectNames" 10 | import { dispatchCameraChange } from "../eventbus/events/cameraChange" 11 | import { dispatchCameraFrameUpdate } from "../eventbus/events/cameraFrameUpdate" 12 | import { 13 | addCanvasResizeListener, 14 | CanvasResizeEvent, 15 | removeCanvasResizeListener, 16 | } from "../eventbus/events/canvasResize" 17 | import { addFrameListener, removeFrameListener } from "../eventbus/events/frame" 18 | import { 19 | addKeyControlListener, 20 | applyDirections, 21 | KeyControlEvent, 22 | removeKeyControlListener, 23 | } from "../eventbus/events/keyControl" 24 | import { isOrthographicCamera } from "../operators/isOrthographicCamera" 25 | import SceneManager from "./SceneManager" 26 | 27 | class CameraManager { 28 | activeCamera: Camera 29 | 30 | private readonly defaultCamera: Camera 31 | private width: number 32 | private height: number 33 | private ballCam: boolean 34 | 35 | private constructor() { 36 | this.activeCamera = SceneManager.getInstance().field.getCamera( 37 | ORANGE_GOAL_CAMERA 38 | )! 39 | this.defaultCamera = this.activeCamera 40 | this.width = 640 41 | this.height = 480 42 | this.ballCam = true 43 | 44 | this.activeCamera.position.z = 5000 45 | this.activeCamera.position.y = 750 46 | 47 | addFrameListener(this.update) 48 | addCanvasResizeListener(this.updateSize) 49 | addKeyControlListener(this.onKeyControl) 50 | } 51 | 52 | toggleBallCam() { 53 | this.ballCam = !this.ballCam 54 | this.update() 55 | } 56 | 57 | private readonly updateSize = ({ width, height }: CanvasResizeEvent) => { 58 | this.width = width 59 | this.height = height 60 | this.updateCameraSize() 61 | } 62 | 63 | private readonly update = () => { 64 | const { position } = SceneManager.getInstance().ball.ball 65 | dispatchCameraFrameUpdate({ 66 | ballPosition: position, 67 | ballCam: this.ballCam, 68 | isUsingBoost: false, 69 | activeCamera: this.activeCamera 70 | }) 71 | 72 | if (!isOrthographicCamera(this.activeCamera)) { 73 | this.activeCamera.lookAt(position) 74 | } 75 | } 76 | 77 | setCameraLocation({ playerName, fieldLocation }: CameraLocationOptions) { 78 | const { players, field } = SceneManager.getInstance() 79 | if (playerName) { 80 | const player = players.find(p => p.playerName === playerName) 81 | if (player) { 82 | this.setActiveCamera(player.camera) 83 | } 84 | } else if (fieldLocation) { 85 | switch (fieldLocation) { 86 | case "orange": 87 | this.setActiveCamera(field.getCamera(ORANGE_GOAL_CAMERA)) 88 | break 89 | case "blue": 90 | this.setActiveCamera(field.getCamera(BLUE_GOAL_CAMERA)) 91 | break 92 | case "center": 93 | this.setActiveCamera(field.getCamera(ABOVE_FIELD_CAMERA)) 94 | break 95 | case "freecam": 96 | const freecam = field.getCamera(FREE_CAMERA) as PerspectiveCamera 97 | if (!isOrthographicCamera(this.activeCamera)) { 98 | if (freecam.parent) { 99 | freecam.parent.updateMatrixWorld() 100 | } 101 | freecam.position.setFromMatrixPosition( 102 | this.activeCamera.matrixWorld 103 | ) 104 | freecam.rotation.fromArray(this.activeCamera.rotation.toArray()) 105 | } 106 | this.setActiveCamera(freecam) 107 | break 108 | case "orthographic-above-field": 109 | this.setActiveCamera(field.getCamera(ORTHOGRAPHIC.ABOVE_FIELD)) 110 | break 111 | case "orthographic-orange-left": 112 | this.setActiveCamera(field.getCamera(ORTHOGRAPHIC.ORANGE_LEFT)) 113 | break 114 | case "orthographic-orange-right": 115 | this.setActiveCamera(field.getCamera(ORTHOGRAPHIC.ORANGE_RIGHT)) 116 | break 117 | case "orthographic-blue-left": 118 | this.setActiveCamera(field.getCamera(ORTHOGRAPHIC.BLUE_LEFT)) 119 | break 120 | case "orthographic-blue-right": 121 | this.setActiveCamera(field.getCamera(ORTHOGRAPHIC.BLUE_RIGHT)) 122 | break 123 | default: 124 | this.setActiveCamera(this.defaultCamera) 125 | } 126 | } 127 | 128 | // Dispatch to all manager listeners 129 | dispatchCameraChange({ camera: this.activeCamera }) 130 | } 131 | 132 | private readonly onKeyControl = ({ directions, speed }: KeyControlEvent) => { 133 | if (this.activeCamera.name === FREE_CAMERA) { 134 | const cameraDirection = new Vector3() 135 | this.activeCamera.getWorldDirection(cameraDirection) 136 | const multiplier = speed ? 75 : 15 137 | const newDirection = applyDirections( 138 | cameraDirection, 139 | directions, 140 | multiplier 141 | ) 142 | const newPosition = new Vector3() 143 | newPosition.copy(this.activeCamera.position) 144 | newPosition.add(newDirection) 145 | 146 | // Don't allow phasing inside of the ball 147 | const { position: ballPosition } = SceneManager.getInstance().ball.ball 148 | if (ballPosition.distanceTo(newPosition) < 200) { 149 | return 150 | } 151 | // Don't allow cameras under the field 152 | if (newPosition.y < 10) { 153 | newPosition.setY(10) 154 | } 155 | 156 | // If all checks pass, set the new position 157 | this.activeCamera.position.copy(newPosition) 158 | this.update() 159 | dispatchCameraChange({ camera: this.activeCamera }) 160 | } 161 | } 162 | 163 | private updateCameraSize() { 164 | const { activeCamera: camera, width, height } = this 165 | 166 | if (camera instanceof PerspectiveCamera) { 167 | camera.aspect = width / height 168 | camera.updateProjectionMatrix() 169 | } else if (camera instanceof OrthographicCamera) { 170 | /** 171 | * Here, we are computing the zoom of the camera given the aspect ratio. For cameras with an 172 | * aspect ratio greater than 4:3, we base the zoom on the height. Otherwise, we use width. The 173 | * minimum zoom should always be 0.02. 174 | * 175 | * The zoom when based on the height is a simple linear function y = x / 12500 + 0.01, where x 176 | * is the new height and y is the desired zoom. 177 | * 178 | * The denominator of the width-based computation is simply the slope of the previous 179 | * function, 12500, multiplied by 1.3 since this is aspect ratio breaking point we have set in 180 | * the if statement. 181 | */ 182 | if (width / height > 1.3) { 183 | const newZoom = height / 12500 + 0.01 184 | camera.zoom = Math.max(newZoom, 0.02) 185 | } else { 186 | const newZoom = width / 16250 + 0.01 187 | camera.zoom = Math.max(newZoom, 0.02) 188 | } 189 | camera.left = -width / 2 190 | camera.right = width / 2 191 | camera.top = height / 2 192 | camera.bottom = -height / 2 193 | camera.updateProjectionMatrix() 194 | } 195 | } 196 | 197 | private setActiveCamera(camera?: Camera) { 198 | if (!camera) { 199 | return 200 | } 201 | this.activeCamera = camera 202 | this.updateCameraSize() 203 | this.update() 204 | } 205 | 206 | /** 207 | * ======================================== 208 | * Managers are singletons 209 | * ======================================== 210 | */ 211 | private static instance?: CameraManager 212 | static getInstance() { 213 | if (!CameraManager.instance) { 214 | throw new Error("CameraManager not initialized with call to `init`") 215 | } 216 | return CameraManager.instance 217 | } 218 | static init() { 219 | CameraManager.instance = new CameraManager() 220 | return CameraManager.instance 221 | } 222 | static destruct() { 223 | const { instance } = CameraManager 224 | if (instance) { 225 | removeFrameListener(instance.update) 226 | removeCanvasResizeListener(instance.updateSize) 227 | removeKeyControlListener(instance.onKeyControl) 228 | CameraManager.instance = undefined 229 | } 230 | } 231 | } 232 | 233 | export interface CameraLocationOptions { 234 | playerName?: string 235 | fieldLocation?: 236 | | "orange" 237 | | "blue" 238 | | "center" 239 | | "freecam" 240 | | "orthographic-blue-right" 241 | | "orthographic-blue-left" 242 | | "orthographic-orange-right" 243 | | "orthographic-orange-left" 244 | | "orthographic-above-field" 245 | } 246 | 247 | export default CameraManager 248 | -------------------------------------------------------------------------------- /src/managers/DataManager.ts: -------------------------------------------------------------------------------- 1 | import { ReplayData } from "../models/ReplayData" 2 | import { ReplayMetadata } from "../models/ReplayMetadata" 3 | 4 | interface DataManagerOptions { 5 | replayData: ReplayData 6 | replayMetadata: ReplayMetadata 7 | } 8 | 9 | export default class DataManager { 10 | data: ReplayData 11 | metadata: ReplayMetadata 12 | 13 | private constructor({ replayData, replayMetadata }: DataManagerOptions) { 14 | this.data = replayData 15 | this.metadata = replayMetadata 16 | } 17 | 18 | public getData() { 19 | return this.data 20 | } 21 | 22 | /** 23 | * ======================================== 24 | * Managers are singletons 25 | * ======================================== 26 | */ 27 | private static instance?: DataManager 28 | static getInstance() { 29 | if (!DataManager.instance) { 30 | throw new Error("DataManager not initialized with call to `init`") 31 | } 32 | return DataManager.instance 33 | } 34 | static init(options: DataManagerOptions) { 35 | DataManager.instance = new DataManager(options) 36 | return DataManager.instance 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/managers/DrawingManager.ts: -------------------------------------------------------------------------------- 1 | import { Group, Camera, Raycaster, SphereBufferGeometry, MeshBasicMaterial, Mesh, Vector3, BufferGeometry, LineBasicMaterial, Line, BufferAttribute, Material, BoxBufferGeometry } from "three" 2 | 3 | import { 4 | addCameraChangeListener, 5 | CameraChangeEvent, 6 | removeCameraChangeListener, 7 | } from "../eventbus/events/cameraChange" 8 | import { 9 | addCanvasResizeListener, 10 | CanvasResizeEvent, 11 | removeCanvasResizeListener, 12 | } from "../eventbus/events/canvasResize" 13 | import SceneManager from "./SceneManager" 14 | import CameraManager from "./CameraManager" 15 | import { GameManager } from "./GameManager" 16 | 17 | export type DrawableMeshIndex = "box" | "sphere" | "line" 18 | 19 | export interface DrawingState { 20 | color?: string 21 | meshScale?: number 22 | drawObject?: DrawableMeshIndex 23 | is3dMode?: boolean 24 | } 25 | 26 | type DrawableMeshes = { 27 | [k in DrawableMeshIndex]: Mesh | Line 28 | } 29 | 30 | interface Canvas { 31 | domNode: HTMLCanvasElement 32 | width: number 33 | height: number 34 | } 35 | 36 | 37 | export default class DrawingManager { 38 | color: string 39 | drawObject: DrawableMeshIndex 40 | is3dMode: boolean 41 | meshScale: number 42 | 43 | private MAX_POINTS: number 44 | private isDrawing: boolean = false 45 | private field: Group 46 | private activeCamera: Camera 47 | private canvas: Canvas 48 | private cloneArray: Array 49 | private activeLinePointIndex: number 50 | private drawableMeshes: DrawableMeshes 51 | 52 | private constructor({color,meshScale,drawObject,is3dMode}: DrawingState = {}) { 53 | this.color = color || "ff0000" 54 | this.drawObject = drawObject || 'line' 55 | this.is3dMode = is3dMode || false 56 | this.meshScale = meshScale || 200 57 | this.MAX_POINTS = 500 58 | this.field = SceneManager.getInstance().field.field 59 | this.activeCamera = CameraManager.getInstance().activeCamera 60 | this.canvas = this.getCanvas() 61 | this.cloneArray = [] 62 | this.activeLinePointIndex = 0 63 | this.drawableMeshes = this.getDrawableMeshes() 64 | 65 | addCanvasResizeListener(this.updateSize) 66 | addCameraChangeListener(this.onCameraChange) 67 | 68 | this.canvas.domNode.addEventListener("mousedown", this.onMouseDown) 69 | this.canvas.domNode.addEventListener("mousemove", this.onMouseMove) 70 | this.canvas.domNode.addEventListener("mouseup", this.onMouseUp) 71 | } 72 | 73 | clearDrawings = () => { 74 | this.removeClones() 75 | } 76 | 77 | setColor = (newColor: string) => { 78 | this.color = newColor 79 | const meshMat = this.drawableMeshes.sphere.material as MeshBasicMaterial 80 | meshMat.color.set(newColor) 81 | const lineMat = this.drawableMeshes.line.material as LineBasicMaterial 82 | lineMat.color.set(newColor) 83 | this.isPaused() && this.refreshFrame() 84 | } 85 | 86 | private readonly getDrawableMeshes = () => { 87 | const basicMaterial = new MeshBasicMaterial({ color: this.color }) 88 | const boxGeometry = new BoxBufferGeometry(0.2, 0.2, 0.2) 89 | const box = new Mesh(boxGeometry, basicMaterial) 90 | 91 | const sphereGeometry = new SphereBufferGeometry(0.1, 32, 32) 92 | const sphere = new Mesh(sphereGeometry, basicMaterial) 93 | 94 | const lineMaterial = new LineBasicMaterial({ color: this.color }) 95 | const lineGeometry = new BufferGeometry() 96 | const positions = new Float32Array(this.MAX_POINTS * 3) 97 | lineGeometry.setAttribute('position', new BufferAttribute(positions, 3)) 98 | const line = new Line(lineGeometry, lineMaterial) 99 | 100 | this.cloneArray.push(sphere.uuid, line.uuid, box.uuid) 101 | 102 | return { box, sphere, line } 103 | } 104 | 105 | private readonly getCanvas = () => { 106 | const domNode = GameManager.getInstance().getDOMNode() 107 | const { width, height } = domNode.getBoundingClientRect() 108 | 109 | return { domNode, width, height } 110 | } 111 | 112 | private readonly onMouseDown = ({ offsetX, offsetY, ctrlKey, altKey }: MouseEvent) => { 113 | this.handleDrawing(offsetX, offsetY) 114 | this.isDrawing = true 115 | } 116 | 117 | private readonly onMouseMove = ({ offsetX, offsetY, ctrlKey, altKey }: MouseEvent) => { 118 | if (this.isDrawing) { 119 | this.handleDrawing(offsetX, offsetY) 120 | } 121 | } 122 | 123 | private readonly onMouseUp = ({ offsetX, offsetY, ctrlKey, altKey }: MouseEvent) => { 124 | this.isDrawing = false 125 | } 126 | 127 | private handleDrawing(offsetX: number, offsetY: number) { 128 | switch (this.drawObject) { 129 | case 'line': 130 | this.drawLine(offsetX, offsetY) 131 | break 132 | default: 133 | this.drawMesh(offsetX, offsetY, this.drawObject) 134 | break 135 | } 136 | } 137 | 138 | private readonly getMouseVector = (offsetX: number, offsetY: number) => { 139 | const cam = this.activeCamera 140 | const scale = this.canvas.width / 2 141 | const x = (offsetX / this.canvas.width) * 2 - 1 142 | const y = -(offsetY / this.canvas.height) * 2 + 1 143 | const rayCaster = new Raycaster() 144 | rayCaster.setFromCamera({ x, y }, cam) 145 | const rayDir = new Vector3(rayCaster.ray.direction.x * scale, rayCaster.ray.direction.y * scale, rayCaster.ray.direction.z * scale) 146 | const rayVector = new Vector3(cam.position.x + rayDir.x, cam.position.y + rayDir.y, cam.position.z + rayDir.z) 147 | if (this.is3dMode) { 148 | const intersections = rayCaster.intersectObjects([this.field], true) 149 | return intersections.length ? intersections[0].point : undefined 150 | } 151 | 152 | return rayVector 153 | } 154 | 155 | private readonly drawLine = (offsetX: number, offsetY: number) => { 156 | const mouseVec = this.getMouseVector(offsetX, offsetY) 157 | if (!mouseVec) return 158 | const index = this.isDrawing ? this.activeLinePointIndex : this.activeLinePointIndex = 0 159 | const activeLine = this.isDrawing ? this.drawableMeshes.line : this.drawableMeshes.line.clone() 160 | if (!this.isDrawing) { 161 | activeLine.geometry = this.drawableMeshes.line.geometry.clone() 162 | this.cloneArray.push(activeLine.uuid) 163 | SceneManager.getInstance().scene.add(activeLine) 164 | this.drawableMeshes.line = activeLine 165 | } 166 | 167 | const geo = activeLine.geometry as BufferGeometry 168 | const positionAttribute = geo.attributes.position as BufferAttribute 169 | const positions = positionAttribute.array as any[] 170 | 171 | positions[index * 3 + 0] = mouseVec.x 172 | positions[index * 3 + 1] = mouseVec.y 173 | positions[index * 3 + 2] = mouseVec.z 174 | 175 | geo.setDrawRange(0, ++this.activeLinePointIndex) 176 | positionAttribute.needsUpdate = true 177 | 178 | this.isPaused() && this.refreshFrame() 179 | } 180 | 181 | private readonly drawMesh = (offsetX: number, offsetY: number, mesh: keyof DrawableMeshes) => { 182 | const rayVector = this.getMouseVector(offsetX, offsetY) 183 | if (rayVector) { 184 | const clone = this.drawableMeshes[mesh].clone() 185 | this.cloneArray.push(clone.uuid) 186 | if (this.is3dMode) rayVector.y += (this.meshScale*0.1) 187 | clone.position.copy(rayVector) 188 | clone.scale.setScalar(this.meshScale) 189 | SceneManager.getInstance().scene.add(clone) 190 | 191 | this.isPaused() && this.refreshFrame() 192 | } 193 | } 194 | 195 | private readonly removeClones = () => { 196 | const scene = SceneManager.getInstance().scene 197 | this.cloneArray.map((i: string) => { 198 | const clone = scene.getObjectByProperty('uuid', i) as Mesh 199 | if (clone) { 200 | (clone.geometry as BufferGeometry).dispose(), 201 | (clone.material as Material).dispose() 202 | scene.remove(clone) 203 | } 204 | }) 205 | 206 | this.isPaused() && this.refreshFrame() 207 | } 208 | 209 | private readonly refreshFrame = () => { 210 | window.requestAnimationFrame(() => { 211 | const gameManager = GameManager.getInstance() 212 | gameManager.render() 213 | gameManager.clock.setFrame(gameManager.clock.currentFrame) 214 | }) 215 | } 216 | 217 | private readonly isPaused = () => { 218 | return GameManager.getInstance().clock.isPaused() 219 | } 220 | 221 | private readonly updateSize = ({ width, height }: CanvasResizeEvent) => { 222 | this.canvas.width = width 223 | this.canvas.height = height 224 | } 225 | 226 | private readonly onCameraChange = ({ camera }: CameraChangeEvent) => { 227 | this.activeCamera = camera 228 | } 229 | 230 | /** 231 | * ======================================== 232 | * Managers are singletons 233 | * ======================================== 234 | */ 235 | private static instance?: DrawingManager 236 | static getInstance() { 237 | if (!DrawingManager.instance) { 238 | throw new Error("DrawingManager not initialized with call to `init`") 239 | } 240 | return DrawingManager.instance 241 | } 242 | static init(state?: DrawingState) { 243 | DrawingManager.instance = new DrawingManager(state) 244 | return DrawingManager.instance 245 | } 246 | static destruct() { 247 | const { instance } = DrawingManager 248 | if (instance) { 249 | removeCameraChangeListener(instance.onCameraChange) 250 | removeCanvasResizeListener(instance.updateSize) 251 | instance.removeClones() 252 | instance.canvas.domNode.removeEventListener("mousedown", instance.onMouseDown) 253 | instance.canvas.domNode.removeEventListener("mousemove", instance.onMouseMove) 254 | instance.canvas.domNode.removeEventListener("mouseup", instance.onMouseUp) 255 | DrawingManager.instance = undefined 256 | } 257 | } 258 | } -------------------------------------------------------------------------------- /src/managers/GameManager.ts: -------------------------------------------------------------------------------- 1 | import { WebGLRenderer } from "three" 2 | 3 | import defaultGameBuilder from "../builders/GameBuilder" 4 | import EventBus from "../eventbus/EventBus" 5 | import { 6 | addCameraChangeListener, 7 | removeCameraChangeListener, 8 | } from "../eventbus/events/cameraChange" 9 | import { 10 | addCanvasResizeListener, 11 | CanvasResizeEvent, 12 | removeCanvasResizeListener, 13 | } from "../eventbus/events/canvasResize" 14 | import { 15 | addFrameListener, 16 | FrameEvent, 17 | removeFrameListener, 18 | } from "../eventbus/events/frame" 19 | import { 20 | addPlayPauseListener, 21 | PlayPauseEvent, 22 | removePlayPauseListener, 23 | } from "../eventbus/events/playPause" 24 | import FPSClock from "../utils/FPSClock" 25 | import AnimationManager from "./AnimationManager" 26 | import CameraManager from "./CameraManager" 27 | import KeyManager from "./KeyManager" 28 | import SceneManager from "./SceneManager" 29 | 30 | interface GameManagerOptions { 31 | clock: FPSClock 32 | } 33 | 34 | export class GameManager { 35 | clock: FPSClock 36 | private readonly renderer: WebGLRenderer 37 | 38 | private constructor({ clock }: GameManagerOptions) { 39 | this.renderer = new WebGLRenderer({ antialias: true }) 40 | this.renderer.shadowMap.enabled = true 41 | this.animate = this.animate.bind(this) 42 | this.render = this.render.bind(this) 43 | this.clock = clock 44 | 45 | // Spawns the animation clips 46 | AnimationManager.getInstance().playAnimationClips() 47 | // Forces every animation to "take position" 48 | AnimationManager.getInstance().updateAnimationClips(0) 49 | addPlayPauseListener(this.onPlayPause) 50 | addFrameListener(this.animate) 51 | addCanvasResizeListener(this.updateSize) 52 | addCameraChangeListener(this.render) 53 | } 54 | 55 | onPlayPause = ({ paused }: PlayPauseEvent) => { 56 | paused ? this.clock.pause() : this.clock.play() 57 | } 58 | 59 | animate({ delta }: FrameEvent) { 60 | if (delta) { 61 | AnimationManager.getInstance().updateAnimationClips(delta) 62 | this.render() 63 | } 64 | } 65 | 66 | getDOMNode() { 67 | return this.renderer.domElement 68 | } 69 | 70 | render = () => { 71 | const { scene } = SceneManager.getInstance() 72 | const { activeCamera } = CameraManager.getInstance() 73 | this.renderer.render(scene, activeCamera) 74 | } 75 | 76 | private readonly updateSize = ({ 77 | width = 640, 78 | height = 480, 79 | }: CanvasResizeEvent) => { 80 | this.renderer.setSize(width, height) 81 | this.render() 82 | } 83 | 84 | /** 85 | * ======================================== 86 | * Managers are singletons 87 | * ======================================== 88 | */ 89 | static builder = defaultGameBuilder 90 | private static instance?: GameManager 91 | static getInstance() { 92 | if (!GameManager.instance) { 93 | throw new Error("GameManager not initialized with call to `init`") 94 | } 95 | return GameManager.instance 96 | } 97 | static init(options: GameManagerOptions) { 98 | GameManager.instance = new GameManager(options) 99 | return GameManager.instance 100 | } 101 | static destruct() { 102 | // Destruct other managers 103 | SceneManager.destruct() 104 | CameraManager.destruct() 105 | KeyManager.destruct() 106 | 107 | // Handle destruction of the existing game 108 | const { instance } = GameManager 109 | if (instance) { 110 | removePlayPauseListener(instance.onPlayPause) 111 | removeFrameListener(instance.animate) 112 | removeCanvasResizeListener(instance.updateSize) 113 | removeCameraChangeListener(instance.render) 114 | instance.clock.reset() 115 | EventBus.reset() 116 | GameManager.instance = undefined 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/managers/KeyManager.ts: -------------------------------------------------------------------------------- 1 | import { FREE_CAMERA } from "../constants/gameObjectNames" 2 | import { 3 | addCameraChangeListener, 4 | CameraChangeEvent, 5 | removeCameraChangeListener, 6 | } from "../eventbus/events/cameraChange" 7 | import { 8 | dispatchKeyControlEvent, 9 | KeyControlEvent, 10 | } from "../eventbus/events/keyControl" 11 | 12 | class KeyManager { 13 | private listening: boolean 14 | private keysPressed: number[] 15 | private interval?: number 16 | private lastKeyPress: number 17 | 18 | private constructor() { 19 | this.listening = false 20 | this.keysPressed = [] 21 | this.lastKeyPress = 0 22 | 23 | addCameraChangeListener(this.onCameraChange) 24 | } 25 | 26 | onCameraChange = ({ camera }: CameraChangeEvent) => { 27 | const isFreeCam = camera.name === FREE_CAMERA 28 | if (isFreeCam !== this.listening) { 29 | this.toggleKeyListener() 30 | } 31 | } 32 | 33 | toggleKeyListener() { 34 | this.listening = !this.listening 35 | if (this.listening) { 36 | document.addEventListener("keyup", this.onKeyUpEvent) 37 | document.addEventListener("keydown", this.onKeyDownEvent) 38 | // If we don't remove these keys on blur/focus, they get "stuck" when refocusing the document 39 | document.addEventListener("focus", this.resetKeyCodes) 40 | document.addEventListener("blur", this.resetKeyCodes) 41 | this.interval = setInterval(this.sendDispatch, 1000 / 30) as any 42 | } else { 43 | document.removeEventListener("keyup", this.onKeyUpEvent) 44 | document.removeEventListener("keydown", this.onKeyDownEvent) 45 | document.removeEventListener("focus", this.resetKeyCodes) 46 | document.removeEventListener("blur", this.resetKeyCodes) 47 | if (this.interval) { 48 | clearInterval(this.interval) 49 | } 50 | } 51 | 52 | return this.listening 53 | } 54 | 55 | private readonly onKeyUpEvent = ({ keyCode }: KeyboardEvent) => { 56 | this.keysPressed = this.keysPressed.filter(code => keyCode !== code) 57 | } 58 | 59 | private readonly onKeyDownEvent = ({ keyCode }: KeyboardEvent) => { 60 | // Kill listeners on escape key 61 | if (keyCode === 27 && this.listening) { 62 | this.toggleKeyListener() 63 | } 64 | 65 | this.lastKeyPress = performance.now() 66 | 67 | if (!this.keysPressed.includes(keyCode)) { 68 | this.keysPressed.push(keyCode) 69 | } 70 | } 71 | 72 | private readonly resetKeyCodes = () => { 73 | this.keysPressed = [] 74 | } 75 | 76 | private readonly sendDispatch = () => { 77 | // The last key press was detected 2000ms ago, shut off dispatch 78 | // TODO: This will shut off dispatches if you press a new key and then release the new key 79 | // without keyup on the first. Should fix for edge case. 80 | if (performance.now() - this.lastKeyPress > 2000) { 81 | this.keysPressed = [] 82 | return 83 | } 84 | 85 | if (this.keysPressed.length) { 86 | const directions: KeyControlEvent["directions"] = [] 87 | let speed = true 88 | this.keysPressed.forEach(keyCode => { 89 | switch (keyCode) { 90 | case 70: // F 91 | speed = false 92 | break 93 | case 37: // Left arrow 94 | case 65: // A 95 | directions.push("left") 96 | break 97 | case 38: // Up arrow 98 | case 87: // W 99 | directions.push("forward") 100 | break 101 | case 39: // Right arrow 102 | case 68: // D 103 | directions.push("right") 104 | break 105 | case 40: // Down arrow 106 | case 83: // S 107 | directions.push("backward") 108 | break 109 | case 32: // Space 110 | directions.push("up") 111 | break 112 | case 16: // Shift 113 | // speed = true 114 | directions.push("down") 115 | break 116 | } 117 | }) 118 | dispatchKeyControlEvent({ 119 | directions, 120 | speed, 121 | }) 122 | } 123 | } 124 | 125 | /** 126 | * ======================================== 127 | * Managers are singletons 128 | * ======================================== 129 | */ 130 | private static instance?: KeyManager 131 | static getInstance() { 132 | if (!KeyManager.instance) { 133 | throw new Error("KeyManager not initialized with call to `init`") 134 | } 135 | return KeyManager.instance 136 | } 137 | static init() { 138 | KeyManager.instance = new KeyManager() 139 | return KeyManager.instance 140 | } 141 | static destruct() { 142 | const { instance } = KeyManager 143 | if (instance) { 144 | removeCameraChangeListener(instance.onCameraChange) 145 | KeyManager.instance = undefined 146 | } 147 | } 148 | } 149 | 150 | export default KeyManager 151 | -------------------------------------------------------------------------------- /src/managers/SceneManager.ts: -------------------------------------------------------------------------------- 1 | import { Scene } from "three" 2 | 3 | import { addFrameListener, removeFrameListener } from "../eventbus/events/frame" 4 | import BallManager from "./models/BallManager" 5 | import FieldManager from "./models/FieldManager" 6 | import PlayerManager from "./models/PlayerManager" 7 | 8 | interface SceneManagerOptions { 9 | scene: Scene 10 | ball: BallManager 11 | field: FieldManager 12 | players: PlayerManager[] 13 | } 14 | 15 | export default class SceneManager { 16 | readonly scene: Scene 17 | readonly ball: BallManager 18 | readonly field: FieldManager 19 | readonly players: PlayerManager[] 20 | 21 | private constructor({ scene, ball, field, players }: SceneManagerOptions) { 22 | this.scene = scene 23 | this.ball = ball 24 | this.field = field 25 | this.players = players 26 | 27 | addFrameListener(this.update) 28 | } 29 | 30 | private readonly update = () => { 31 | for (const player of this.players) { 32 | player.carGroup.visible = player.carGroup.position.y >= 0; 33 | } 34 | } 35 | 36 | /** 37 | * ======================================== 38 | * Managers are singletons 39 | * ======================================== 40 | */ 41 | private static instance?: SceneManager 42 | static getInstance() { 43 | if (!SceneManager.instance) { 44 | throw new Error("SceneManager not initialized with call to `init`") 45 | } 46 | return SceneManager.instance 47 | } 48 | static init(options: SceneManagerOptions) { 49 | SceneManager.instance = new SceneManager(options) 50 | return SceneManager.instance 51 | } 52 | static destruct() { 53 | const { instance } = SceneManager 54 | if (instance) { 55 | removeFrameListener(instance.update) 56 | SceneManager.instance = undefined 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/managers/models/BallManager.ts: -------------------------------------------------------------------------------- 1 | import { Object3D } from "three" 2 | 3 | class BallManager { 4 | readonly ball: Object3D 5 | 6 | constructor(ball: Object3D) { 7 | this.ball = ball 8 | } 9 | } 10 | 11 | export default BallManager 12 | -------------------------------------------------------------------------------- /src/managers/models/FieldManager.ts: -------------------------------------------------------------------------------- 1 | import { Camera, Group } from "three" 2 | 3 | class FieldManager { 4 | readonly field: Group 5 | readonly cameras: Camera[] 6 | 7 | constructor(field: Group, cameras: Camera[]) { 8 | this.field = field 9 | this.cameras = cameras 10 | } 11 | 12 | getCamera(cameraName: string) { 13 | return this.cameras.find(camera => camera.name === cameraName) 14 | } 15 | } 16 | 17 | export default FieldManager 18 | -------------------------------------------------------------------------------- /src/managers/models/PlayerManager.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Camera, 3 | Group, OrthographicCamera, 4 | PerspectiveCamera, 5 | Sprite, 6 | Vector3, 7 | } from "three" 8 | 9 | import { SPRITE } from "../../constants/gameObjectNames" 10 | import { 11 | addCameraChangeListener, 12 | CameraChangeEvent, 13 | } from "../../eventbus/events/cameraChange" 14 | import { 15 | addCameraFrameUpdateListener, 16 | CameraFrameUpdateEvent, 17 | } from "../../eventbus/events/cameraFrameUpdate" 18 | import { SPRITE_ORTHO_SCALE } from "../../builders/player/generateSprite"; 19 | 20 | const CAMERA_ABOVE_PLAYER = 200 21 | 22 | export default class PlayerManager { 23 | readonly carGroup: Group 24 | readonly playerName: string 25 | readonly isOrangeTeam: boolean 26 | 27 | readonly camera: PerspectiveCamera 28 | private activeCamera: boolean 29 | 30 | private readonly sprite: Sprite 31 | 32 | constructor(playerName: string, isOrangeTeam: boolean, carGroup: Group) { 33 | this.playerName = playerName 34 | this.carGroup = carGroup 35 | this.isOrangeTeam = isOrangeTeam 36 | 37 | this.sprite = this.carGroup.children.find( 38 | child => child.name === SPRITE 39 | ) as Sprite 40 | 41 | this.camera = new PerspectiveCamera(80, 2, 0.1, 20000) 42 | this.activeCamera = false 43 | this.carGroup.add(this.camera) 44 | 45 | addCameraChangeListener(this.onCameraChange) 46 | addCameraFrameUpdateListener(this.onCameraFrameUpdate) 47 | } 48 | 49 | onCameraChange = ({ camera }: CameraChangeEvent) => { 50 | const isActiveCamera = camera === this.camera 51 | this.toggleSprite(!isActiveCamera) 52 | this.activeCamera = isActiveCamera 53 | this.updateSprite(camera) 54 | } 55 | 56 | onCameraFrameUpdate = ({ ballPosition, activeCamera }: CameraFrameUpdateEvent) => { 57 | // Ignore frame updates if we aren't the active camera 58 | if (!this.activeCamera) { 59 | this.updateSprite(activeCamera) 60 | return 61 | } 62 | 63 | // Compute the position where the camera should sit on opposite side of player from ball 64 | const vectorToBall = new Vector3() 65 | const scaleFromPlayer = 300 66 | vectorToBall.subVectors(this.carGroup.position, ballPosition) 67 | vectorToBall.setLength(scaleFromPlayer) 68 | vectorToBall.y += CAMERA_ABOVE_PLAYER 69 | 70 | // Correct for camera going beneath the map 71 | if (vectorToBall.y < 0) { 72 | const lowYFactor = 15 73 | vectorToBall.setLength( 74 | lowYFactor / -vectorToBall.y + (scaleFromPlayer - lowYFactor) 75 | ) 76 | vectorToBall.y = 0 77 | } 78 | const camera = this.camera 79 | // TODO: Tween FOV 80 | // if (isUsingBoost && camera.fov === 80) { 81 | // camera.fov = 85 82 | // camera.updateProjectionMatrix() 83 | // } else if (camera.fov === 85) { 84 | // camera.fov = 80 85 | // camera.updateProjectionMatrix() 86 | // } 87 | camera.position.copy(vectorToBall) 88 | } 89 | 90 | updateSprite(activeCamera: Camera) { 91 | if (!this.activeCamera) { 92 | if (activeCamera instanceof OrthographicCamera) { 93 | this.sprite.scale.setScalar(SPRITE_ORTHO_SCALE) 94 | } else { 95 | const spritePos = this.sprite.localToWorld(new Vector3()) 96 | const camPos = activeCamera.localToWorld(new Vector3()) 97 | const scale = spritePos.distanceTo(camPos) / 3 98 | this.sprite.scale.setScalar(scale) 99 | } 100 | } 101 | } 102 | 103 | private toggleSprite(display: boolean) { 104 | this.sprite.visible = display 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/models/Game.ts: -------------------------------------------------------------------------------- 1 | type GameMode = "1's" | "2's" | "3's" | "Standard" 2 | 3 | interface GameScore { 4 | team0Score: number 5 | team1Score: number 6 | } 7 | 8 | export { GameMode, GameScore } 9 | -------------------------------------------------------------------------------- /src/models/Replay.ts: -------------------------------------------------------------------------------- 1 | import { GameMode, GameScore } from "./Game" 2 | import { ReplayPlayer } from "./ReplayPlayer" 3 | 4 | interface Replay { 5 | id: string 6 | name: string 7 | date: import("moment").Moment 8 | map: string 9 | gameMode: GameMode 10 | gameScore: GameScore 11 | players: ReplayPlayer[] 12 | tags: Tag[] 13 | } 14 | 15 | interface Tag { 16 | name: string 17 | ownerId: string 18 | } 19 | 20 | export { Replay } 21 | -------------------------------------------------------------------------------- /src/models/ReplayData.ts: -------------------------------------------------------------------------------- 1 | interface ReplayData { 2 | ball: BallFrame[] // [pos_x, pos_z, pos_y, rot_x, rot_z, rot_y] 3 | colors: boolean[] 4 | frames: Frame[] 5 | names: string[] 6 | id: string 7 | players: PlayerFrame[][] // Each frame contains a PlayerFrame for each player 8 | } 9 | 10 | type Frame = [Delta, GameTime, ElapsedTime] 11 | type Delta = number 12 | type GameTime = number 13 | type ElapsedTime = number 14 | 15 | type BallFrame = [PosX, PosZ, PosY, RotX, RotZ, RotY] 16 | type PlayerFrame = [PosX, PosZ, PosY, RotX, RotZ, RotY, Boost] 17 | type PosX = number 18 | type PosY = number 19 | type PosZ = number 20 | type RotX = number 21 | type RotY = number 22 | type RotZ = number 23 | type Boost = boolean 24 | 25 | export { ReplayData } 26 | -------------------------------------------------------------------------------- /src/models/ReplayMetadata.ts: -------------------------------------------------------------------------------- 1 | import { ReplayPlayer } from "./ReplayPlayer" 2 | 3 | interface ReplayMetadata { 4 | gameMetadata: GameMetadata 5 | players: ExtendedPlayer[] 6 | version: number 7 | mutators?: { 8 | ballType: "string" 9 | gameMutatorIndex: number 10 | } 11 | // TODO 12 | teams: any[] 13 | gameStats: any 14 | } 15 | 16 | interface GameMetadata { 17 | id: string 18 | name: string 19 | map: string 20 | version: number 21 | time: string 22 | frames: number 23 | score: { 24 | team0Score: number 25 | team1Score: number 26 | } 27 | goals: Goal[] 28 | } 29 | 30 | interface Goal { 31 | frameNumber: number 32 | playerId: { id: string } 33 | } 34 | 35 | type Omit = Pick> 36 | interface ExtendedPlayer extends Omit { 37 | id: { id: string } 38 | } 39 | 40 | export { ReplayMetadata, Goal, ExtendedPlayer } 41 | -------------------------------------------------------------------------------- /src/models/ReplayPlayer.ts: -------------------------------------------------------------------------------- 1 | interface ReplayPlayer { 2 | id: string 3 | name: string 4 | isOrange: boolean 5 | score: number 6 | goals: number 7 | assists: number 8 | saves: number 9 | shots: number 10 | cameraSettings: CameraSettings 11 | loadout: Loadout 12 | } 13 | 14 | interface CameraSettings { 15 | distance: number 16 | fieldOfView: number 17 | transitionSpeed: number 18 | pitch: number 19 | swivelSpeed: number 20 | stiffness: number 21 | height: number 22 | } 23 | 24 | interface Loadout { 25 | antenna: number 26 | banner: number 27 | boost: number 28 | car: number 29 | engineAudio: number 30 | goalExplosion: number 31 | skin: number 32 | topper: number 33 | trail: number 34 | wheels: number 35 | primaryColor: number 36 | accentColor: number 37 | bannerPaint: number 38 | boostPaint: number 39 | carPaint: number 40 | goalExplosionPaint: number 41 | skinPaint: number 42 | trailPaint: number 43 | wheelsPaint: number 44 | topperPaint: number 45 | antennaPaint: number 46 | } 47 | 48 | export { ReplayPlayer } 49 | -------------------------------------------------------------------------------- /src/operators/frameGetters.ts: -------------------------------------------------------------------------------- 1 | import { ReplayData } from "../models/ReplayData" 2 | 3 | const getFrame = (replayData: ReplayData, frameNumber: number) => 4 | replayData.frames[frameNumber] 5 | 6 | export const getDelta = (replayData: ReplayData, frameNumber: number) => 7 | getFrame(replayData, frameNumber)[0] 8 | export const getGameTime = (replayData: ReplayData, frameNumber: number) => 9 | getFrame(replayData, frameNumber)[1] 10 | export const getElapsedTime = (replayData: ReplayData, frameNumber: number) => 11 | getFrame(replayData, frameNumber)[2] 12 | -------------------------------------------------------------------------------- /src/operators/isOrthographicCamera.ts: -------------------------------------------------------------------------------- 1 | import { Camera, OrthographicCamera } from "three" 2 | 3 | export const isOrthographicCamera = (camera: Camera) => 4 | !!(camera as OrthographicCamera).isOrthographicCamera 5 | -------------------------------------------------------------------------------- /src/operators/metadataGetters.ts: -------------------------------------------------------------------------------- 1 | import { ReplayMetadata } from "../models/ReplayMetadata" 2 | 3 | export const getPlayerById = ( 4 | replayMetadata: ReplayMetadata, 5 | playerId: string 6 | ) => replayMetadata.players.find(player => player.id.id === playerId) 7 | -------------------------------------------------------------------------------- /src/types/glb.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.glb" { 2 | const value: string 3 | export default value 4 | } 5 | -------------------------------------------------------------------------------- /src/types/json.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.json" { 2 | const value: any 3 | export default value 4 | } 5 | -------------------------------------------------------------------------------- /src/utils/FPSClock.ts: -------------------------------------------------------------------------------- 1 | import { dispatchFrameEvent } from "../eventbus/events/frame" 2 | import { ReplayData } from "../models/ReplayData" 3 | 4 | /** 5 | * This clock provides a simple callback system that keeps track of elapsed and delta time 6 | * transformations. This makes it extremely easy to parse the deltas of a replay by their frame 7 | * count, and maintain a real-time comparison against those frames. Primarily to be used by the 8 | * THREE.js animation system and communicate changes back to the parent container of animations so 9 | * that we can display data which is recorded at a frame and not at a time. 10 | * 11 | * When constructing this object, you should provide an array of elapsed durations*1000, where each 12 | * index in the array represents the time in milliseconds since the beginning of the animation. The 13 | * first index, 0, should be set to 0 (the elapsed time since the start), followed by these times. 14 | * 15 | * For example: 16 | * 0: 0 17 | * 1: 483.877919614315 18 | * 2: 838.5848999023438 19 | * 3: 1322.4628567695618 20 | * 4: 1809.6305429935455 21 | */ 22 | export default class FPSClock { 23 | public currentFrame: number 24 | // Used only to keep track of the elapsed time between getDelta calls 25 | private lastDelta: number 26 | // Stores a queue of deltas that get applied and returned by getDelta. See getDelta for why 27 | private readonly deltaQueue: number[] 28 | // Used only to determine which frame we should be on during updates 29 | private elapsedTime: number 30 | 31 | // Represented as an index in the array to the elapsed time at that frame 32 | private readonly frameToDuration: number[] 33 | 34 | private paused: boolean 35 | private animation?: number 36 | 37 | constructor(frameToDuration: number[]) { 38 | this.frameToDuration = frameToDuration 39 | this.paused = true 40 | this.deltaQueue = [] 41 | this.elapsedTime = 0 42 | this.currentFrame = 0 43 | this.lastDelta = 0 44 | 45 | this.update = this.update.bind(this) 46 | this.timeout() 47 | } 48 | 49 | public reset() { 50 | this.setFrame(0) 51 | } 52 | 53 | public setFrame(frame: number) { 54 | // Prevent negative frames 55 | frame = frame < 0 ? 0 : frame 56 | const diff = 57 | this.frameToDuration[frame] - this.frameToDuration[this.currentFrame] 58 | this.deltaQueue.push(diff) 59 | this.currentFrame = frame 60 | this.doCallbacks() 61 | } 62 | 63 | public isPaused() { 64 | return this.paused 65 | } 66 | 67 | public play() { 68 | if (this.paused) { 69 | this.lastDelta = performance.now() 70 | this.paused = false 71 | this.timeout() 72 | } 73 | } 74 | 75 | public pause() { 76 | this.paused = true 77 | this.doCallbacks() 78 | this.timeout(false) 79 | } 80 | 81 | /** 82 | * Returns the elapsed time in milliseconds. 83 | */ 84 | public getElapsedTime() { 85 | return this.frameToDuration[this.currentFrame] 86 | } 87 | 88 | /** 89 | * Returns the number of seconds elapsed since the last time getDelta was called. This function 90 | * uses a combination of the performance.now() functionality when animations are rolling, 91 | * combined with a small queue of delta modifications made by the setFrame function. This will 92 | * allow us to apply delta factors quite easily and in one spot (i.e. 2x speed) as opposed to 93 | * scattering arithmetic throughout the code. 94 | * 95 | * @returns {number} seconds 96 | */ 97 | private getDelta(): number { 98 | const now = performance.now() 99 | // Initialize empty delta 100 | if (!this.lastDelta) { 101 | this.lastDelta = now 102 | } 103 | // Only apply "now" when not paused 104 | if (!this.paused) { 105 | this.deltaQueue.push(now - this.lastDelta) 106 | } 107 | this.lastDelta = now 108 | // Process every delta contributer 109 | let delta = 0 110 | while (this.deltaQueue.length) { 111 | const time = this.deltaQueue.pop() 112 | if (time) { 113 | delta += time 114 | } 115 | } 116 | // Use the elapsed deltas for bookkeeping 117 | this.elapsedTime += delta 118 | return delta / 1000 119 | } 120 | 121 | private update() { 122 | if (!this.paused) { 123 | this.getElapsedFrames() 124 | this.doCallbacks() 125 | } 126 | } 127 | 128 | private getElapsedFrames() { 129 | if (this.currentFrame >= this.frameToDuration.length - 1) { 130 | this.pause() 131 | } 132 | if (this.frameToDuration[this.currentFrame] >= this.elapsedTime) { 133 | this.currentFrame = 0 134 | } 135 | while (this.frameToDuration[this.currentFrame + 1] < this.elapsedTime) { 136 | this.currentFrame += 1 137 | } 138 | } 139 | 140 | private timeout(enable: boolean = true) { 141 | if (enable) { 142 | this.animation = setInterval(this.update, 1000 / 60) as any 143 | } else if (this.animation) { 144 | clearInterval(this.animation as any) 145 | } 146 | } 147 | 148 | private doCallbacks() { 149 | dispatchFrameEvent({ 150 | delta: this.getDelta(), 151 | frame: this.currentFrame, 152 | elapsedTime: this.getElapsedTime(), 153 | }) 154 | } 155 | 156 | /** 157 | * Note that the final frame is ignored when considering the elapsed time per frame. If we 158 | * considered this final delta, we would need a frame to "animate to". 159 | * 160 | * @param data Contains frame delta information 161 | */ 162 | public static convertReplayToClock(data: ReplayData) { 163 | let elapsedTime = 0 164 | const frames = data.frames.map((frameInfo: number[]) => { 165 | const retValue = elapsedTime 166 | const delta = frameInfo[0] * 1000 167 | elapsedTime += delta 168 | return retValue 169 | }) 170 | return new FPSClock(frames) 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/utils/addToWindow.ts: -------------------------------------------------------------------------------- 1 | export const addToWindow = (name: string, object: any) => { 2 | const w = window as any 3 | w[name] = object 4 | } 5 | -------------------------------------------------------------------------------- /src/utils/hashCode.ts: -------------------------------------------------------------------------------- 1 | import { addToWindow } from "./addToWindow" 2 | 3 | // tslint:disable 4 | export const hashCode = (s: string) => { 5 | return s.split("").reduce((a, b) => { 6 | a = (a << 5) - a + b.charCodeAt(0) 7 | return a & a 8 | }, 0) 9 | } 10 | 11 | addToWindow("hash", hashCode) 12 | -------------------------------------------------------------------------------- /src/utils/isDevelopment.ts: -------------------------------------------------------------------------------- 1 | export const isDevelopment = () => process.env.NODE_ENV === "development" 2 | -------------------------------------------------------------------------------- /src/viewer/clients/loadBuilderFromReplay.ts: -------------------------------------------------------------------------------- 1 | import { GameManager } from "../../managers/GameManager" 2 | import FPSClock from "../../utils/FPSClock" 3 | import { loadReplay } from "./loadReplay" 4 | 5 | export const loadBuilderFromReplay = async ( 6 | replayId: string, 7 | defaultLoadouts: boolean = false 8 | ) => { 9 | return loadReplay(replayId).then(([replayData, replayMetadata]) => { 10 | return GameManager.builder({ 11 | replayData, 12 | replayMetadata, 13 | clock: FPSClock.convertReplayToClock(replayData), 14 | defaultLoadouts, 15 | }) 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /src/viewer/clients/loadReplay.ts: -------------------------------------------------------------------------------- 1 | import { ReplayData } from "../../models/ReplayData" 2 | import { ReplayMetadata } from "../../models/ReplayMetadata" 3 | 4 | const fetchByURL = (url: string, local?: boolean) => 5 | fetch(url, local ? {} : { 6 | method: "GET", 7 | headers: { 8 | Accept: "application/json", 9 | "Content-Type": "application/json", 10 | }, 11 | }).then(response => response.json()) 12 | 13 | const cache: { [key: string]: [ReplayData, ReplayMetadata] } = {} 14 | 15 | export const loadReplay = async ( 16 | replayId: string, 17 | cached?: boolean, 18 | local?: boolean, 19 | usecgg: boolean=true 20 | ): Promise<[ReplayData, ReplayMetadata]> => { 21 | 22 | var remoteAddress 23 | if (usecgg) { 24 | remoteAddress = "https://calculated.gg/api" 25 | } else { 26 | remoteAddress = location.protocol + '//' + location.host 27 | } 28 | 29 | const url = local ? "../examples/" : remoteAddress + "/replay/" 30 | const fetch = () => 31 | Promise.all([ 32 | fetchByURL(`${url+replayId}/positions${local? '.json':''}`, local), 33 | fetchByURL(`${url+replayId}${local ? "/metadata.json" : "?key=1"}`, local), 34 | ]) 35 | if (cached) { 36 | if (!cache[replayId]) { 37 | return fetch().then(data => { 38 | cache[replayId] = data 39 | return data 40 | }) 41 | } 42 | return Promise.resolve(cache[replayId]) 43 | } 44 | return fetch() 45 | } 46 | -------------------------------------------------------------------------------- /src/viewer/components/CompactPlayControls.tsx: -------------------------------------------------------------------------------- 1 | import Button from "@mui/material/Button" 2 | import Dialog from "@mui/material/Dialog" 3 | import DialogContent from "@mui/material/DialogContent" 4 | import DialogTitle from "@mui/material/DialogTitle" 5 | import Grid from "@mui/material/Grid" 6 | import Typography from "@mui/material/Typography" 7 | import React, { Component } from "react" 8 | import styled from "styled-components" 9 | 10 | import { 11 | addCameraChangeListener, 12 | removeCameraChangeListener, 13 | } from "../../eventbus/events/cameraChange" 14 | import { 15 | addPlayPauseListener, 16 | dispatchPlayPauseEvent, 17 | PlayPauseEvent, 18 | removePlayPauseListener, 19 | } from "../../eventbus/events/playPause" 20 | import FieldCameraControls from "./FieldCameraControls" 21 | import Camera from "./icons/Camera" 22 | import PausedIcon from "./icons/PausedIcon" 23 | import PlayIcon from "./icons/PlayIcon" 24 | import PlayerCameraControls from "./PlayerCameraControls" 25 | import Slider from "./Slider" 26 | 27 | interface Props { 28 | } 29 | 30 | interface State { 31 | paused: boolean 32 | cameraControlsShowing: boolean 33 | } 34 | 35 | export default class CompactPlayControls extends Component { 36 | constructor(props: Props) { 37 | super(props) 38 | this.state = { 39 | paused: false, 40 | cameraControlsShowing: false, 41 | } 42 | 43 | addPlayPauseListener(this.onPlayPause) 44 | addCameraChangeListener(this.hideCameraControls) 45 | } 46 | 47 | componentWillUnmount() { 48 | removePlayPauseListener(this.onPlayPause) 49 | removeCameraChangeListener(this.hideCameraControls) 50 | } 51 | 52 | setPlayPause = () => { 53 | const isPaused = this.state.paused 54 | dispatchPlayPauseEvent({ 55 | paused: !isPaused, 56 | }) 57 | } 58 | 59 | onPlayPause = ({ paused }: PlayPauseEvent) => { 60 | this.setState({ 61 | paused, 62 | }) 63 | } 64 | 65 | showCameraControls = () => { 66 | this.setState({ 67 | cameraControlsShowing: true, 68 | }) 69 | } 70 | 71 | hideCameraControls = () => { 72 | this.setState({ 73 | cameraControlsShowing: false, 74 | }) 75 | } 76 | 77 | render() { 78 | const { paused, cameraControlsShowing } = this.state 79 | return ( 80 | 81 | 82 | 83 | 86 | 87 | 88 | 96 | 97 | 98 | 101 | 102 | 103 | 104 | Camera Controls 105 | 106 | Field Cameras 107 | 108 | Player Cameras 109 | 110 | 111 | 112 | 113 | ) 114 | } 115 | } 116 | 117 | const ControlsWrapper = styled.div` 118 | position: absolute; 119 | bottom: 6px; 120 | left: 12px; 121 | right: 60px; 122 | ` 123 | -------------------------------------------------------------------------------- /src/viewer/components/DrawingControls.tsx: -------------------------------------------------------------------------------- 1 | import Button from "@mui/material/Button" 2 | import Grid from "@mui/material/Grid" 3 | import ButtonGroup from "@mui/material/ButtonGroup" 4 | import Input from "@mui/material/Input" 5 | import InputAdornment from "@mui/material/InputAdornment" 6 | 7 | import ColorIcon from "./icons/ColorIcon" 8 | import DropDownIcon from "./icons/DropDownIcon" 9 | import DeleteIcon from "./icons/DeleteIcon" 10 | import PencilIcon from "./icons/PencilIcon" 11 | import PencilOffIcon from "./icons/PencilOffIcon" 12 | import SphereIcon from "./icons/SphereIcon" 13 | import LineIcon from "./icons/LineIcon" 14 | 15 | import React, { PureComponent } from "react" 16 | import styled from "styled-components" 17 | 18 | import DrawingManager, { DrawableMeshIndex } from "../../managers/DrawingManager" 19 | import BoxIcon from "./icons/BoxIcon" 20 | 21 | interface Props { } 22 | 23 | interface State { 24 | isDrawingMode: boolean 25 | color: string 26 | meshScale: number 27 | drawObject: DrawableMeshIndex 28 | is3dMode: boolean 29 | } 30 | 31 | class DrawingControls extends PureComponent { 32 | colorPicker: React.RefObject 33 | constructor(props: Props) { 34 | super(props) 35 | this.state = { 36 | isDrawingMode: false, 37 | color: "#00ea0c", 38 | meshScale: 200, 39 | drawObject: "line", 40 | is3dMode: false, 41 | } 42 | this.colorPicker = React.createRef() 43 | } 44 | 45 | toggleDrawingMode = () => { 46 | const isDrawingMode = !this.state.isDrawingMode 47 | this.setState({ 48 | isDrawingMode, 49 | }) 50 | isDrawingMode ? DrawingManager.init(this.state) : DrawingManager.destruct() 51 | } 52 | 53 | toggle3dMode = () => { 54 | const is3dMode = !this.state.is3dMode 55 | this.setState({ 56 | is3dMode: is3dMode 57 | }) 58 | DrawingManager.getInstance().is3dMode = is3dMode 59 | } 60 | 61 | changecolor = (e: React.ChangeEvent) => { 62 | const color = e.target.value 63 | this.setState({ 64 | color: color, 65 | }) 66 | DrawingManager.getInstance().setColor(color) 67 | } 68 | 69 | toggleColorPicker = () => { 70 | this.colorPicker.current && this.colorPicker.current.click() 71 | } 72 | 73 | changeSelectedDrawObject = (object: DrawableMeshIndex) => { 74 | if (this.state.drawObject === object) return 75 | this.setState({ 76 | drawObject: object, 77 | }) 78 | DrawingManager.getInstance().drawObject = object 79 | } 80 | 81 | changeMeshScale = (e: React.ChangeEvent) => { 82 | const scale = Number(e.target.value) 83 | this.setState({ 84 | meshScale: scale, 85 | }) 86 | DrawingManager.getInstance().meshScale = scale 87 | } 88 | 89 | clearDrawings = () => { 90 | DrawingManager.getInstance().clearDrawings() 91 | } 92 | 93 | renderControlButtons = () => { 94 | const drawableMeshes:DrawableMeshIndex[] = ['box','sphere','line'] 95 | const meshButtons = { 96 | sphere: SphereIcon, 97 | line: LineIcon, 98 | box: BoxIcon 99 | } 100 | return ( 101 | 102 | 103 | 104 | 114 | 117 | 120 | {drawableMeshes.map((value, index) => { 121 | const IconButton = meshButtons[value]; 122 | return 129 | })} 130 | 131 | 132 | {this.state.drawObject == "sphere" || this.state.drawObject == "box" 133 | ? 134 | Scale} 140 | /> 141 | 142 | : null 143 | } 144 | 145 | ) 146 | } 147 | 148 | render() { 149 | return ( 150 | 151 | 152 | 155 | 156 | {this.state.isDrawingMode && this.renderControlButtons()} 157 | 158 | ) 159 | } 160 | } 161 | 162 | const HiddenInput = styled.input` 163 | && { 164 | display: none; 165 | } 166 | ` 167 | 168 | export default DrawingControls 169 | -------------------------------------------------------------------------------- /src/viewer/components/FieldCameraControls.tsx: -------------------------------------------------------------------------------- 1 | import Button, { ButtonProps } from "@mui/material/Button" 2 | import Dialog from "@mui/material/Dialog" 3 | import List from "@mui/material/List" 4 | import ListItem from "@mui/material/ListItem" 5 | import Typography from "@mui/material/Typography" 6 | import React, { PureComponent } from "react" 7 | import styled from "styled-components" 8 | 9 | import CameraManager, { 10 | CameraLocationOptions, 11 | } from "../../managers/CameraManager" 12 | 13 | const options: CameraLocationOptions["fieldLocation"][] = [ 14 | "blue", 15 | "orange", 16 | "center", 17 | "freecam", 18 | ] 19 | const optionNames = { 20 | blue: "Blue Goal", 21 | orange: "Orange Goal", 22 | center: "Above Field", 23 | freecam: "Free Cam", 24 | } 25 | const orthographicOptions: CameraLocationOptions["fieldLocation"][] = [ 26 | "orthographic-above-field", 27 | "orthographic-blue-left", 28 | "orthographic-blue-right", 29 | "orthographic-orange-left", 30 | "orthographic-orange-right", 31 | ] 32 | const orthographicOptionNames = { 33 | ["orthographic-above-field"]: "Above Field", 34 | ["orthographic-blue-left"]: "Blue Left", 35 | ["orthographic-blue-right"]: "Blue Right", 36 | ["orthographic-orange-left"]: "Orange Left", 37 | ["orthographic-orange-right"]: "Orange Right", 38 | } 39 | 40 | interface Props {} 41 | 42 | interface State { 43 | dialogOpen: boolean 44 | } 45 | 46 | class FieldCameraControls extends PureComponent { 47 | constructor(props: Props) { 48 | super(props) 49 | this.state = { 50 | dialogOpen: false, 51 | } 52 | } 53 | 54 | toggleDialog = () => { 55 | const dialogOpen = !this.state.dialogOpen 56 | this.setState({ 57 | dialogOpen, 58 | }) 59 | } 60 | 61 | onFieldClick = (fieldLocation: CameraLocationOptions["fieldLocation"]) => { 62 | return () => { 63 | if (this.state.dialogOpen) { 64 | this.setState({ 65 | dialogOpen: false, 66 | }) 67 | } 68 | CameraManager.getInstance().setCameraLocation({ fieldLocation }) 69 | } 70 | } 71 | 72 | renderFieldButtons() { 73 | return options.map(option => { 74 | return ( 75 | 80 | {(optionNames as any)[option || "center"]} 81 | 82 | ) 83 | }) 84 | } 85 | 86 | renderOrthographicOptions() { 87 | return ( 88 | 89 | 90 | {orthographicOptions.map(option => { 91 | return ( 92 | 97 | 98 | { 99 | (orthographicOptionNames as any)[ 100 | option || "orthographic-orange-right" 101 | ] 102 | } 103 | 104 | 105 | ) 106 | })} 107 | 108 | 109 | ) 110 | } 111 | 112 | render() { 113 | return ( 114 |
115 | {this.renderFieldButtons()} 116 | 117 | Orthographic 118 | 119 | {this.renderOrthographicOptions()} 120 |
121 | ) 122 | } 123 | } 124 | 125 | const FieldButton = styled(Button)` 126 | && { 127 | margin: 6px; 128 | } 129 | ` as React.ComponentType 130 | 131 | export default FieldCameraControls 132 | -------------------------------------------------------------------------------- /src/viewer/components/GameManagerLoader.tsx: -------------------------------------------------------------------------------- 1 | import CircularProgress from "@mui/material/CircularProgress" 2 | import Typography, { TypographyProps } from "@mui/material/Typography" 3 | import React, { Component } from "react" 4 | import styled from "styled-components" 5 | import { LoadingManager } from "three/src/loaders/LoadingManager" 6 | 7 | import { GameBuilderOptions } from "../../builders/GameBuilder" 8 | import { GameManager } from "../../managers/GameManager" 9 | import ErrorIcon from "./icons/ErrorIcon" 10 | 11 | interface Props { 12 | options: GameBuilderOptions 13 | onLoad: (manager: GameManager) => void 14 | } 15 | 16 | interface State { 17 | loadingManager: LoadingManager 18 | percentLoaded: number 19 | gameManager?: GameManager 20 | error?: string 21 | } 22 | 23 | class GameManagerLoader extends Component { 24 | constructor(props: Props) { 25 | super(props) 26 | 27 | this.state = { 28 | percentLoaded: 0, 29 | loadingManager: props.options.loadingManager || new LoadingManager(), 30 | } 31 | this.state.loadingManager.onProgress = this.handleProgress 32 | if (!this.state.loadingManager.onError) { 33 | this.state.loadingManager.onError = this.handleError 34 | } 35 | } 36 | 37 | componentDidMount() { 38 | GameManager.builder({ 39 | ...this.props.options, 40 | loadingManager: this.state.loadingManager, 41 | }).then(gameManager => { 42 | this.props.onLoad(gameManager) 43 | this.setState({ 44 | gameManager, 45 | }) 46 | }) 47 | } 48 | 49 | componentWillUnmount() { 50 | GameManager.destruct() 51 | } 52 | 53 | handleProgress = (_: any, loaded: number, total: number) => { 54 | const newPercent = Math.round((loaded / total) * 1000) / 10 55 | const { percentLoaded } = this.state 56 | const stateValue = newPercent > percentLoaded ? newPercent : percentLoaded 57 | this.setState({ 58 | percentLoaded: stateValue, 59 | }) 60 | } 61 | 62 | handleError = (message: string) => { 63 | this.setState({ 64 | error: `An error occurred while loading: ${message}`, 65 | }) 66 | } 67 | 68 | render() { 69 | const { children } = this.props 70 | const { gameManager } = this.state 71 | if (!gameManager) { 72 | return ( 73 | 74 | 75 | {this.renderIcon()} 76 | 77 | {this.getHintText()} 78 | 79 | ) 80 | } 81 | return children 82 | } 83 | 84 | private renderIcon(): JSX.Element { 85 | const { error, percentLoaded } = this.state 86 | if (error) { 87 | return 88 | } 89 | const variant = percentLoaded === 100 ? "indeterminate" : "determinate" 90 | return 91 | } 92 | 93 | private getHintText(): string { 94 | const { percentLoaded, error } = this.state 95 | if (error) { 96 | return error 97 | } else if (percentLoaded === 100) { 98 | return "Building scene..." 99 | } else { 100 | return `Importing assets: ${percentLoaded}%` 101 | } 102 | } 103 | } 104 | 105 | const LoadingContainer = styled.div` 106 | width: 100%; 107 | position: relative; 108 | text-align: center; 109 | ` 110 | const CircularProgressContainer = styled.div` 111 | width: 100%; 112 | height: 40px; 113 | display: flex; 114 | align-items: center; 115 | justify-content: center; 116 | ` 117 | const Type = styled(Typography)` 118 | width: 100%; 119 | padding-top: 16px; 120 | ` as React.ComponentType 121 | 122 | export default GameManagerLoader 123 | -------------------------------------------------------------------------------- /src/viewer/components/PlayControls.tsx: -------------------------------------------------------------------------------- 1 | import Button from "@mui/material/Button" 2 | import Grid from "@mui/material/Grid" 3 | import React, { Component } from "react" 4 | 5 | import { 6 | addPlayPauseListener, 7 | dispatchPlayPauseEvent, 8 | PlayPauseEvent, 9 | removePlayPauseListener, 10 | } from "../../eventbus/events/playPause" 11 | import { GameManager } from "../../managers/GameManager" 12 | 13 | interface Props {} 14 | 15 | interface State { 16 | paused: boolean 17 | } 18 | 19 | export default class PlayControls extends Component { 20 | constructor(props: Props) { 21 | super(props) 22 | this.state = { 23 | paused: false, 24 | } 25 | 26 | addPlayPauseListener(this.onPlayPause) 27 | } 28 | 29 | componentWillUnmount() { 30 | removePlayPauseListener(this.onPlayPause) 31 | } 32 | 33 | setPlayPause = () => { 34 | const isPaused = this.state.paused 35 | dispatchPlayPauseEvent({ 36 | paused: !isPaused, 37 | }) 38 | } 39 | 40 | onPlayPause = ({ paused }: PlayPauseEvent) => { 41 | this.setState({ 42 | paused, 43 | }) 44 | } 45 | 46 | render() { 47 | const { clock } = GameManager.getInstance() 48 | const onResetClick = () => clock.setFrame(0) 49 | 50 | return ( 51 | 52 | 53 | 56 | 57 | 58 | 61 | 62 | 63 | ) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/viewer/components/PlayerCameraControls.tsx: -------------------------------------------------------------------------------- 1 | import Button, { ButtonProps } from "@mui/material/Button" 2 | import Grid from "@mui/material/Grid" 3 | import React, { PureComponent } from "react" 4 | import styled from "styled-components" 5 | 6 | import CameraManager, { 7 | CameraLocationOptions, 8 | } from "../../managers/CameraManager" 9 | import PlayerManager from "../../managers/models/PlayerManager" 10 | import SceneManager from "../../managers/SceneManager" 11 | 12 | interface Props {} 13 | 14 | class PlayerCameraControls extends PureComponent { 15 | constructor(props: Props) { 16 | super(props) 17 | } 18 | 19 | onPlayerClick = (playerName: string) => { 20 | return () => CameraManager.getInstance().setCameraLocation({ playerName }) 21 | } 22 | 23 | onFieldClick = (fieldLocation: CameraLocationOptions["fieldLocation"]) => { 24 | return () => 25 | CameraManager.getInstance().setCameraLocation({ fieldLocation }) 26 | } 27 | 28 | renderPlayerButtons() { 29 | const { players } = SceneManager.getInstance() 30 | const renderTeam = (team: PlayerManager[]) => 31 | team.map(({ playerName }) => { 32 | return ( 33 | 38 | {playerName} 39 | 40 | ) 41 | }) 42 | return ( 43 | 44 | 45 | {renderTeam(players.filter(player => player.isOrangeTeam))} 46 | 47 | 48 | {renderTeam(players.filter(player => !player.isOrangeTeam))} 49 | 50 | 51 | ) 52 | } 53 | 54 | render() { 55 | return
{this.renderPlayerButtons()}
56 | } 57 | } 58 | 59 | const PlayerButton = styled(Button)` 60 | && { 61 | margin: 6px; 62 | } 63 | ` as React.ComponentType 64 | 65 | export default PlayerCameraControls 66 | -------------------------------------------------------------------------------- /src/viewer/components/ReplayViewer.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Typography } from "@mui/material" 2 | import React, { useEffect, useRef } from "react" 3 | import { FullScreen, useFullScreenHandle } from "react-full-screen" 4 | import styled from "styled-components" 5 | 6 | import { dispatchCanvasResizeEvent } from "../../eventbus/events/canvasResize" 7 | import { dispatchPlayPauseEvent } from "../../eventbus/events/playPause" 8 | import { GameManager } from "../../managers/GameManager" 9 | import FullscreenExitIcon from "./icons/FullscreenExitIcon" 10 | import FullscreenIcon from "./icons/FullscreenIcon" 11 | import Scoreboard from "./ScoreBoard" 12 | 13 | interface Props { 14 | gameManager: GameManager; 15 | autoplay?: boolean; 16 | children?: React.ReactNode; 17 | } 18 | 19 | const ReplayViewer = (props: Props): JSX.Element => { 20 | const mount = useRef(null) 21 | useEffect(() => { 22 | if (!mount.current) { 23 | throw new Error("Did not mount replay viewer correctly") 24 | } 25 | const { gameManager, autoplay } = props 26 | // Mount and resize canvas 27 | mount.current.appendChild(gameManager.getDOMNode()) 28 | handleResize() 29 | 30 | // Set the play/pause status to match autoplay property 31 | dispatchPlayPauseEvent({ paused: !autoplay }) 32 | 33 | addEventListener("resize", handleResize) 34 | }, []) 35 | 36 | const handleResize = () => { 37 | const { clientWidth: width, clientHeight: height } = mount.current! 38 | dispatchCanvasResizeEvent({ width, height }) 39 | } 40 | 41 | const handle = useFullScreenHandle() 42 | return ( 43 | 44 | 47 |
48 | 49 | 50 | 55 | 56 | {props.children} 57 | 58 | 59 | ) 60 | } 61 | 62 | const ViewerContainer = styled.div` 63 | width: 100%; 64 | height: 480px; 65 | position: relative; 66 | .fullscreen { 67 | width: 100%; 68 | height: 100%; 69 | } 70 | ` 71 | 72 | const FullscreenToggle = styled.div` 73 | position: absolute; 74 | bottom: 0; 75 | right: 0; 76 | ` 77 | 78 | export default ReplayViewer 79 | -------------------------------------------------------------------------------- /src/viewer/components/ScoreBoard.tsx: -------------------------------------------------------------------------------- 1 | import debounce from "lodash.debounce" 2 | import React, { PureComponent } from "react" 3 | import styled from "styled-components" 4 | 5 | import { 6 | addFrameListener, 7 | FrameEvent, 8 | removeFrameListener, 9 | } from "../../eventbus/events/frame" 10 | import DataManager from "../../managers/DataManager" 11 | import { Goal } from "../../models/ReplayMetadata" 12 | import { getGameTime } from "../../operators/frameGetters" 13 | import { getPlayerById } from "../../operators/metadataGetters" 14 | 15 | interface Props {} 16 | interface State { 17 | team0Score: number 18 | team1Score: number 19 | gameTime: number 20 | } 21 | 22 | export default class Scoreboard extends PureComponent { 23 | onFrame = debounce( 24 | ({ frame }: FrameEvent) => { 25 | const { data } = DataManager.getInstance() 26 | const gameTime = getGameTime(data, frame) 27 | if (gameTime !== this.state.gameTime) { 28 | this.setState({ gameTime }) 29 | } 30 | this.updateGameScore(frame) 31 | }, 32 | 250, 33 | { maxWait: 250 } 34 | ) 35 | 36 | constructor(props: Props) { 37 | super(props) 38 | this.state = { 39 | team0Score: 0, 40 | team1Score: 0, 41 | gameTime: 300, 42 | } 43 | 44 | addFrameListener(this.onFrame) 45 | } 46 | 47 | componentWillUnmount() { 48 | removeFrameListener(this.onFrame) 49 | this.onFrame.cancel() 50 | } 51 | 52 | getTimerString() { 53 | let { gameTime } = this.state 54 | // The frame is filled as -100 when the data is missing, so we set the minimum to 0 55 | gameTime = gameTime < 0 ? 0 : gameTime 56 | const seconds = gameTime % 60 57 | const minutes = (gameTime - seconds) / 60 58 | const secondsString = seconds < 10 ? `0${seconds}` : seconds 59 | return `${minutes}:${secondsString}` 60 | } 61 | 62 | render() { 63 | const { team0Score, team1Score } = this.state 64 | return ( 65 | 66 | 67 | {team0Score} 68 | 69 | 70 | {this.getTimerString()} 71 | 72 | 73 | {team1Score} 74 | 75 | 76 | ) 77 | } 78 | 79 | private updateGameScore(frameNumber: number) { 80 | const { metadata } = DataManager.getInstance() 81 | 82 | let team0Score = 0 83 | let team1Score = 0 84 | metadata.gameMetadata.goals.forEach((goal: Goal) => { 85 | if (goal.frameNumber <= frameNumber) { 86 | const player = getPlayerById(metadata, goal.playerId.id) 87 | if (player) { 88 | if (player.isOrange) { 89 | team1Score++ 90 | } else { 91 | team0Score++ 92 | } 93 | } 94 | } 95 | }) 96 | if ( 97 | team0Score !== this.state.team0Score || 98 | team1Score !== this.state.team1Score 99 | ) { 100 | this.setState({ team0Score, team1Score }) 101 | } 102 | } 103 | } 104 | 105 | const ScoreContainer = styled.div` 106 | display: flex; 107 | align-items: stretch; 108 | top: 0; 109 | position: absolute; 110 | z-index: 10; 111 | left: 50%; 112 | text-align: center; 113 | transform: translateX(-50%); 114 | width: 400px; 115 | border-style: solid; 116 | border-width: 3px; 117 | border-color: #fffa; 118 | border-bottom-right-radius: 10px; 119 | border-bottom-left-radius: 10px; 120 | ` 121 | const OrangeScoreCard = styled.div` 122 | background-color: #e27740aa; 123 | border-bottom-right-radius: 5px; 124 | flex: 1; 125 | ` 126 | const BlueScoreCard = styled.div` 127 | background-color: #4874efaa; 128 | border-bottom-left-radius: 5px; 129 | flex: 1; 130 | ` 131 | const Score = styled.div` 132 | color: #fff; 133 | font-family: monospace; 134 | font-size: xx-large; 135 | ` 136 | const GameTimeCard = styled.div` 137 | background-color: #000a; 138 | ` 139 | const GameTime = styled.div` 140 | color: #fff; 141 | font-family: monospace; 142 | font-size: xx-large; 143 | width: 100px; 144 | ` 145 | -------------------------------------------------------------------------------- /src/viewer/components/Slider.tsx: -------------------------------------------------------------------------------- 1 | import MUISlider, { SliderProps } from "@mui/material/Slider" 2 | import debounce from "lodash.debounce" 3 | import React, { Component } from "react" 4 | 5 | import { 6 | addFrameListener, 7 | FrameEvent, 8 | removeFrameListener, 9 | } from "../../eventbus/events/frame" 10 | import DataManager from "../../managers/DataManager" 11 | import { GameManager } from "../../managers/GameManager" 12 | 13 | interface Props extends Partial {} 14 | 15 | interface State { 16 | frame: number 17 | maxFrame: number 18 | } 19 | 20 | const SLIDER_OUTLINE_RADIUS = 24 21 | 22 | class Slider extends Component { 23 | onFrame = debounce( 24 | ({ frame }: FrameEvent) => { 25 | this.setState({ frame }) 26 | }, 27 | 250, 28 | { maxWait: 250 } 29 | ) 30 | 31 | constructor(props: Props) { 32 | super(props) 33 | this.state = { 34 | frame: 0, 35 | maxFrame: DataManager.getInstance().data.frames.length - 1, 36 | } 37 | addFrameListener(this.onFrame) 38 | } 39 | 40 | componentWillUnmount() { 41 | removeFrameListener(this.onFrame) 42 | this.onFrame.cancel() 43 | } 44 | 45 | handleChange = (_: any, value: number | number[]) => { 46 | const frame = Math.round(value as number) 47 | GameManager.getInstance().clock.setFrame(frame) 48 | } 49 | 50 | render() { 51 | const { frame, maxFrame } = this.state 52 | 53 | return ( 54 |
63 |
74 | 82 |
83 |
84 | ) 85 | } 86 | } 87 | 88 | export default Slider 89 | -------------------------------------------------------------------------------- /src/viewer/components/icons/BoxIcon.tsx: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | 3 | import React from "react" 4 | 5 | type Props = { 6 | color?: string, 7 | } 8 | 9 | const BoxIcon = ({color = "#000000"}: Props) => { 10 | return ( 11 |
12 | 18 | 19 | 20 |
21 | ) 22 | } 23 | 24 | export default BoxIcon -------------------------------------------------------------------------------- /src/viewer/components/icons/Camera.tsx: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | 3 | import React from "react" 4 | 5 | const Camera = () => { 6 | return ( 7 |
8 | 14 | 18 | 19 |
20 | ) 21 | } 22 | 23 | export default Camera 24 | -------------------------------------------------------------------------------- /src/viewer/components/icons/ColorIcon.tsx: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | 3 | import React from "react" 4 | 5 | type Props = { 6 | color?: string, 7 | selectedColor?: string 8 | } 9 | 10 | const ColorIcon = ({color = "#000000", selectedColor = "#000000"}: Props) => { 11 | return ( 12 |
13 | 19 | 23 | 24 | 25 |
26 | ) 27 | } 28 | 29 | export default ColorIcon 30 | -------------------------------------------------------------------------------- /src/viewer/components/icons/DeleteIcon.tsx: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | 3 | import React from "react" 4 | 5 | type Props = { 6 | color?: string, 7 | } 8 | 9 | const DeleteIcon = ({color = "#000000"}: Props) => { 10 | return ( 11 |
12 | 18 | 19 | 20 |
21 | ) 22 | } 23 | 24 | export default DeleteIcon -------------------------------------------------------------------------------- /src/viewer/components/icons/DropDownIcon.tsx: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | 3 | import React from "react" 4 | 5 | const DropDownIcon = () => { 6 | return ( 7 |
8 | 14 | 18 | 19 |
20 | ) 21 | } 22 | 23 | export default DropDownIcon 24 | -------------------------------------------------------------------------------- /src/viewer/components/icons/ErrorIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import SvgIcon from "@mui/material/SvgIcon" 3 | 4 | type Props = { 5 | color?: string 6 | } 7 | 8 | const ErrorIcon = ({ color }: Props) => { 9 | return ( 10 | 11 | 15 | 16 | ) 17 | } 18 | 19 | export default ErrorIcon 20 | -------------------------------------------------------------------------------- /src/viewer/components/icons/FullscreenExitIcon.tsx: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | 3 | import React from "react" 4 | 5 | const FullscreenExitIcon = () => { 6 | return ( 7 |
8 | 14 | 18 | 19 |
20 | ) 21 | } 22 | 23 | export default FullscreenExitIcon 24 | -------------------------------------------------------------------------------- /src/viewer/components/icons/FullscreenIcon.tsx: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | 3 | import React from "react" 4 | 5 | const FullscreenIcon = () => { 6 | return ( 7 | 13 | 17 | 18 | ) 19 | } 20 | 21 | export default FullscreenIcon 22 | -------------------------------------------------------------------------------- /src/viewer/components/icons/LineIcon.tsx: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | 3 | import React from "react" 4 | 5 | type Props = { 6 | color?: string, 7 | } 8 | 9 | const LineIcon = ({color = "#000000"}: Props) => { 10 | return ( 11 |
12 | 18 | 19 | 20 |
21 | ) 22 | } 23 | 24 | export default LineIcon -------------------------------------------------------------------------------- /src/viewer/components/icons/PausedIcon.tsx: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | 3 | import React from "react" 4 | 5 | const PausedIcon = () => { 6 | return ( 7 |
8 | 14 | 15 | 16 |
17 | ) 18 | } 19 | 20 | export default PausedIcon 21 | -------------------------------------------------------------------------------- /src/viewer/components/icons/PencilIcon.tsx: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | 3 | import React from "react" 4 | 5 | type Props = { 6 | color?: string, 7 | } 8 | 9 | const PencilIcon = ({color = "#000000"}: Props) => { 10 | return ( 11 |
12 | 18 | 21 | 22 |
23 | ) 24 | } 25 | 26 | export default PencilIcon -------------------------------------------------------------------------------- /src/viewer/components/icons/PencilOffIcon.tsx: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | 3 | import React from "react" 4 | 5 | type Props = { 6 | color?: string, 7 | } 8 | 9 | const PencilIcon = ({color = "#000000"}: Props) => { 10 | return ( 11 |
12 | 18 | 21 | 22 |
23 | ) 24 | } 25 | 26 | export default PencilIcon -------------------------------------------------------------------------------- /src/viewer/components/icons/PlayIcon.tsx: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | 3 | import React from "react" 4 | 5 | const PlayIcon = () => { 6 | return ( 7 |
8 | 14 | 15 | 16 |
17 | ) 18 | } 19 | 20 | export default PlayIcon 21 | -------------------------------------------------------------------------------- /src/viewer/components/icons/SphereIcon.tsx: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | 3 | import React from "react" 4 | 5 | type Props = { 6 | color?: string, 7 | } 8 | 9 | const SphereIcon = ({color = "#000000"}: Props) => { 10 | return ( 11 |
12 | 18 | 19 | 20 |
21 | ) 22 | } 23 | 24 | export default SphereIcon -------------------------------------------------------------------------------- /src/wrapper/withGameManager.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SaltieRL/WebReplayViewer/21e392d9f319c146b84e14340d85f5720bd18c5f/src/wrapper/withGameManager.ts -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "declaration": true, 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "jsx": "react", 8 | "lib": ["es6", "dom"], 9 | "module": "esnext", 10 | "moduleResolution": "node", 11 | "noEmit": false, 12 | "noImplicitAny": true, 13 | "noImplicitReturns": true, 14 | "noImplicitThis": true, 15 | "noUnusedLocals": true, 16 | "outDir": "lib", 17 | "resolveJsonModule": true, 18 | "skipLibCheck": true, 19 | "sourceMap": true, 20 | "strict": true, 21 | "strictNullChecks": true, 22 | "suppressImplicitAnyIndexErrors": true, 23 | "target": "es5", 24 | "typeRoots": ["./node_modules/@types"] 25 | }, 26 | "include": ["src/**/*"], 27 | "exclude": ["node_modules", "lib", "lib-esm"] 28 | } 29 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path") 2 | 3 | const PATHS = { 4 | entry: path.resolve(__dirname, "src/index.ts"), 5 | bundles: path.resolve(__dirname, "lib"), 6 | assets: "assets/models/draco", 7 | } 8 | 9 | module.exports = { 10 | mode: "production", 11 | entry: PATHS.entry, 12 | output: { 13 | path: PATHS.bundles, 14 | filename: "[name].bundle.js", 15 | libraryTarget: "umd", 16 | library: "ReplayViewer", 17 | umdNamedDefine: true, 18 | }, 19 | resolve: { 20 | extensions: [".ts", ".tsx", ".js"], 21 | }, 22 | devtool: "source-map", 23 | plugins: [], 24 | optimization: { 25 | minimize: true, 26 | }, 27 | module: { 28 | rules: [ 29 | { 30 | test: /\.tsx?$/, 31 | exclude: /(node_modules)/, 32 | use: { 33 | loader: "babel-loader", 34 | options: {}, 35 | }, 36 | }, 37 | { 38 | test: /\.(glb|mtl)$/, 39 | use: [ 40 | { 41 | loader: "file-loader", 42 | options: { 43 | outputPath: PATHS.assets, 44 | name(file) { 45 | if (process.env.NODE_ENV === "development") { 46 | return "[path][name].[ext]" 47 | } 48 | return "[name].[ext]" 49 | }, 50 | }, 51 | }, 52 | ], 53 | }, 54 | ], 55 | }, 56 | } 57 | --------------------------------------------------------------------------------