├── .eslintrc.js ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── RELEASE_NOTES.md ├── docs ├── A_category_introduction.md ├── B_category_overview.md ├── C_category_sync.md ├── D_category_tutorials.md ├── E_category_extras.md ├── MyFirstGame.md ├── articleSettings.json ├── choosing_a_physics_engine.md ├── furtherreading.md ├── guide_clientengine.md ├── guide_gameengine.md ├── guide_gameworld.md ├── guide_renderer.md ├── guide_serialization.md ├── guide_serverengine.md ├── guide_syncextrapolation.md ├── guide_syncinterpolation.md ├── guide_tuningdebugging.md ├── introduction.md ├── introduction_community.md ├── introduction_faq.md ├── introduction_prologue.md ├── introduction_roadmap.md ├── overview_architecture.md └── spaceships.md ├── jsdoc.conf.json ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── ClientEngine.ts ├── GameEngine.ts ├── GameWorld.ts ├── ServerEngine.ts ├── Synchronizer.ts ├── controls │ └── KeyboardControls.ts ├── game │ └── Timer.ts ├── lib │ ├── MathUtils.ts │ ├── Scheduler.ts │ ├── Trace.ts │ ├── Utils.ts │ └── lib.ts ├── network │ ├── NetworkMonitor.ts │ ├── NetworkTransmitter.ts │ └── NetworkedEventCollection.ts ├── package │ ├── allExports.ts │ ├── clientExports.ts │ └── serverExports.ts ├── physics │ ├── CannonPhysicsEngine.ts │ ├── P2PhysicsEngine.ts │ ├── PhysicsEngine.ts │ ├── SimplePhysics │ │ ├── BruteForceCollisionDetection.ts │ │ ├── CollisionDetection.ts │ │ ├── HSHG.ts │ │ └── HSHGCollisionDetection.ts │ └── SimplePhysicsEngine.ts ├── render │ ├── AFrameRenderer.ts │ ├── Renderer.ts │ ├── ThreeRenderer.ts │ ├── aframe │ │ └── system.ts │ └── pixi │ │ ├── PixiRenderableComponent.ts │ │ └── PixiRenderer.ts ├── serialize │ ├── BaseTypes.ts │ ├── DynamicObject.ts │ ├── GameComponent.ts │ ├── GameObject.ts │ ├── NetScheme.ts │ ├── PhysicalObject2D.ts │ ├── PhysicalObject3D.ts │ ├── Quaternion.ts │ ├── Serializable.ts │ ├── Serializer.ts │ ├── ThreeVector.ts │ └── TwoVector.ts └── syncStrategies │ ├── ExtrapolateStrategy.ts │ ├── FrameSyncStrategy.ts │ ├── InterpolateStrategy.ts │ └── SyncStrategy.ts ├── test ├── EndToEnd │ ├── multiplayer.js │ └── testGame │ │ ├── client.js │ │ ├── server.js │ │ └── src │ │ ├── client │ │ ├── MyClientEngine.js │ │ └── MyRenderer.js │ │ ├── common │ │ ├── MyGameEngine.js │ │ └── PlayerAvatar.js │ │ └── server │ │ └── MyServerEngine.js ├── SimplePhysics │ ├── HSHG.js │ └── HSHGGameEngine.js └── serializer │ ├── list.js │ ├── primitives.js │ └── string.js ├── tsconfig.json └── utils ├── lanceInfo.js ├── npm-install.sh └── showMovement.js /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": "google", 3 | "env": { 4 | "browser": true, 5 | "node": true, 6 | "mocha": true 7 | }, 8 | "installedESLint": true, 9 | "parserOptions": { 10 | "ecmaVersion": 6 11 | }, 12 | "rules": { 13 | "arrow-parens": "off", 14 | "brace-style": ["error", "1tbs", { "allowSingleLine": true }], 15 | "comma-dangle": "off", 16 | "quotes": ["error", "single"], 17 | "guard-for-in": "off", 18 | "indent": ["error", 4], 19 | "linebreak-style": ["error", "unix"], 20 | "max-len": "off", 21 | "max-statements-per-line": ["error", { "max": 2 }], 22 | "no-alert": "off", 23 | "no-console": "off", 24 | "no-warning-comments": "off", 25 | "object-curly-spacing": ["error", "always"], 26 | "padded-blocks": "off", 27 | "require-jsdoc": "off" 28 | } 29 | 30 | }; 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | *.trace 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | 15 | # transpiled code 16 | # es5 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | 24 | # nyc test coverage 25 | .nyc_output 26 | 27 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 28 | .grunt 29 | 30 | # node-waf configuration 31 | .lock-wscript 32 | 33 | # Compiled binary addons (http://nodejs.org/api/addons.html) 34 | build/Release 35 | 36 | # Dependency directories 37 | node_modules 38 | jspm_packages 39 | 40 | # Optional npm cache directory 41 | .npm 42 | 43 | # Optional REPL history 44 | .node_repl_history 45 | 46 | #jetbrains project files 47 | .idea 48 | 49 | # jsdoc output 50 | docs_out 51 | 52 | # yarn files 53 | yarn.lock 54 | 55 | 56 | deploy_key 57 | docmeta_temp 58 | docmeta_temp_deploy 59 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "6" 4 | env: 5 | global: 6 | - ENCRYPTION_LABEL: "d436d79ac9c9" 7 | - COMMIT_AUTHOR_EMAIL: "opherv@gmail.com" 8 | after_script: 9 | - bash ./deploydocs.sh -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Lance logo 2 | 3 | # [Lance](https://lance-gg.github.io/) is a real-time multiplayer game server 4 | 5 | It provides an extendible Node.JS based server, on which game logic runs, as well as a client-side library 6 | which synchronizes the client's game state with the server game state. In order 7 | to provide a smooth visual experience for each connected client, Lance implements 8 | efficient networking methods, position interpolation and extrapolation, user input 9 | coordination, shadow objects, physics and pseudo-physical movement, automatic 10 | handling of network spikes. 11 | 12 | Lance aims to optimize the player's visual experience, while providing 13 | a simple development model which is highly configurable and easy to analyze 14 | and debug. 15 | 16 | ## Features: 17 | 18 | * Focus on writing your game. Lance takes care of the netcode 19 | * Can support any type of game or genre 20 | * Optimized networking 21 | * TCP via websockets 22 | * Communication is packed and serialized into binary 23 | * Automatic handling of network spikes with step correction 24 | * Intelligent sync strategies for lag handling 25 | * Extrapolation (client side prediction) with step re-enactment or: 26 | * Interpolation for optimal object motion 27 | * Tools for debugging and tracing 28 | 29 | More features in the pipeline: 30 | 31 | * UDP via WebRTC 32 | * Full-stack testing suite 33 | * Replay saving 34 | * More physics engines 35 | 36 | ## That's so neat! Where do I start? 37 | 38 | The official [Lance documentation](https://lance-gg.github.io/docs_out/index.html) contains articles on theory and rationale, as well as the structure and architecture of the project. 39 | 40 | ## Something went wrong! I need help! 41 | 42 | If you're not exactly sure how to do something, [Stack Overflow](http://stackoverflow.com/questions/tagged/lance) is your friend. 43 | 44 | If you've encountered a bug and it's not already in the [issues page](https://github.com/lance-gg/lance/issues), open a new issue. 45 | 46 | -------------------------------------------------------------------------------- /RELEASE_NOTES.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Release Notes for Lance 4 | 5 | ## Release 5.0.1 - May 2024 6 | 7 | * TypeScript Support 8 | * major version upgrades on dependencies - fixing npm audit 9 | * move to newer socket.io 10 | 11 | 12 | ## Release 3.0.0 - July 2018 13 | 14 | * 2D Physics Support via P2 15 | * New sample game Asteroids 16 | * Interpolation mode 17 | * Common bending code 18 | 19 | ## Release 2.0.0 - February 2018 20 | 21 | ### New Features 22 | 23 | * new netscheme data type: *STRING*. Will only be broadcast if it changed since last broadcast. 24 | * PhysicsEngine no longer initialized in two places. It is initialized in the GameEngine 25 | * Implemented HSHG collision detection for SimplePhysics 26 | * Implemented ClientEngine standaloneMode for network-less testing of game engines 27 | * New KeyboardControls class to help with sending key-based input 28 | * Moved to es6 modules on all games 29 | * ES6 Modules: Modules are imported using the "import from" construct. For example import GameEngine from 'lance/GameEngine' instead of const GameEngine = require(...) 30 | * ES6 Modules: Games must configure webpack.config.js. See sample game 31 | * ES6 Modules: Babel must be configured in order to run es6 modules on node server-side, by creating a .babelrc file. See sample game 32 | * Renderer-controlled game loop support: the physics engine is asked to step forwards by dt, where dt is the time between current frame render and last frame render 33 | * full-sync support for updating all game objects when new player connects 34 | * Renderer refactored as a singleton, and instantiated only on client 35 | * Render objects and Physics objects are now sub-objects of the GameObject 36 | 37 | ### Breaking Changes 38 | 39 | * All classes are now in ES6 format instead of CommonJS 40 | * `PhysicsEngine` should no longer be instantiated in the Server `main.js` and in the client entry point. Rather, it should be instantiated in the `GameEngine` subclass for your game. 41 | * `PhysicsEngine` constructor now does all initialization. Use of `init` function is deprecated. 42 | * `GameEngine` step method cannot be called without passing the `isReenact` argument. Games which override the `step` method must pass this argument when calling the super method. 43 | * `GameObject` New `onRemoveFromWorld` to mirror `onAddToWorld` 44 | * Objects are now instantiated with a reference to the gameEngine, and get and ID automatically 45 | * Method `isOwnedByPlayer` moved from `clientEngine` to `GameEngine`, and the `clientEngine` now sets the `playerId` in the `gameEngine`. `GameObject` constructor is therefore: constructor(gameEngine, options, props) and must call the super constructor correspondingly 46 | * The `GameWorld.getPlayerObject()` method has been removed, you can get the player objects using the `GameWorld.query()` method, passing a `playerId` attribute. 47 | * constructors of `DynamicObject` and `PhysicalObject` have changed to the following: gameEngine, options, and props. 48 | 49 | ## Release 1.0.1 50 | 51 | ### Breaking Changes 52 | 53 | 1. Event `preInput` was renamed to `processInput`, `client__processInput`, `server__processInput`. `postInput`. This is a breaking change but no one actually used these events. 54 | 55 | ## Release 1.0.0 - March 2017 56 | 57 | ### New Features 58 | 59 | * Server only sends updates for objects which changed 60 | * **A-Frame** support 61 | * **Cannon.js** support 62 | * **3D** support 63 | 64 | 65 | ### Breaking Changes 66 | 67 | Note that this is a major release update, which breaks current 68 | games. To upgrade to this latest major **release 1.0.0**, you will need 69 | to apply the following changes: 70 | 71 | 1. Option `frameRate` has been renamed to `stepRate`. 72 | 2. `ClientEngine` constructor receives a third argument, a `Renderer` class. Not a `Renderer` instance but the class itself. 73 | 3. Class `Point` has been removed. Replaced by `TwoVector` and `ThreeVector`. 74 | 4. `DynamicObject` no longer has attributes `x`, `y`, `velX`, `velY`. instead it has attributes `position` and `velocity` which are instances of `TwoVector`. The `DynamicObject` constructor also has changed to receive arguments `position` and `velocity`. 75 | 5. The concept of bending has been redefined so that 0.0 means no bending 76 | towards the server value, and 1.0 means total bending towards the server 77 | value until the next server update. 78 | -------------------------------------------------------------------------------- /docs/A_category_introduction.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lance-gg/lance/fd9bc5dce93f59684acc0c862a3a7849b993f65a/docs/A_category_introduction.md -------------------------------------------------------------------------------- /docs/B_category_overview.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lance-gg/lance/fd9bc5dce93f59684acc0c862a3a7849b993f65a/docs/B_category_overview.md -------------------------------------------------------------------------------- /docs/C_category_sync.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lance-gg/lance/fd9bc5dce93f59684acc0c862a3a7849b993f65a/docs/C_category_sync.md -------------------------------------------------------------------------------- /docs/D_category_tutorials.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lance-gg/lance/fd9bc5dce93f59684acc0c862a3a7849b993f65a/docs/D_category_tutorials.md -------------------------------------------------------------------------------- /docs/E_category_extras.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lance-gg/lance/fd9bc5dce93f59684acc0c862a3a7849b993f65a/docs/E_category_extras.md -------------------------------------------------------------------------------- /docs/articleSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "A_category_introduction": { 3 | "title": "Introduction", 4 | "order": 1, 5 | "children": { 6 | "introduction": { 7 | "title": "Introduction" 8 | }, 9 | "introduction_prologue": { 10 | "title": "Prologue" 11 | }, 12 | "introduction_community": { 13 | "title": "Community" 14 | }, 15 | "introduction_roadmap": { 16 | "title": "Development Roadmap" 17 | }, 18 | "introduction_faq": { 19 | "title": "Frequently Asked Questions" 20 | } 21 | } 22 | }, 23 | "B_category_overview": { 24 | "title": "Overview", 25 | "order": 2, 26 | "children": { 27 | "overview_architecture":{ 28 | "title": "Architecture of a Multiplayer Game" 29 | }, 30 | "choosing_a_physics_engine":{ 31 | "title": "Choosing a Physics Engine" 32 | }, 33 | "guide_gameengine":{ 34 | "title": "Game Engine" 35 | }, 36 | "guide_serverengine": { 37 | "title": "Server Engine" 38 | }, 39 | "guide_clientengine": { 40 | "title": "Client Engine" 41 | }, 42 | "guide_renderer": { 43 | "title": "Renderer" 44 | }, 45 | "guide_gameworld": { 46 | "title": "Game World and Game Objects" 47 | }, 48 | "guide_serialization": { 49 | "title": "Serialization and Communication" 50 | } 51 | } 52 | }, 53 | "C_category_sync":{ 54 | "title": "Synchronization Methods", 55 | "order": 30, 56 | "children": { 57 | "guide_syncinterpolation": { 58 | "title": "Interpolation" 59 | }, 60 | "guide_syncextrapolation": { 61 | "title": "Extrapolation" 62 | } 63 | } 64 | }, 65 | "D_category_tutorials": { 66 | "title": "Tutorials", 67 | "order": 50, 68 | "children": { 69 | "MyFirstGame":{ 70 | "title": "My First Game: Pong" 71 | }, 72 | "spaceships": { 73 | "title": "Spaaace" 74 | } 75 | } 76 | }, 77 | "E_category_extras": { 78 | "title": "Extras", 79 | "order": 200, 80 | "children": { 81 | "guide_tuningdebugging": { 82 | "title": "Fine Tuning and Debugging", 83 | "order": 80 84 | }, 85 | "furtherreading": { 86 | "title": "Further Reading", 87 | "order": 100 88 | } 89 | } 90 | } 91 | 92 | } 93 | -------------------------------------------------------------------------------- /docs/choosing_a_physics_engine.md: -------------------------------------------------------------------------------- 1 | 2 | There are many different kinds of multiplayer games, and different games have different approaches to physics. Lance supports three basic physics models. 3 | 4 | * **2D Simple Physics**: A pseudo-physics arcade mode, which only tracks position, velocity, and angle for each object. 5 | * **2D Physics Engine**: A true physics engine mode, based on the engine [P2](https://github.com/schteppe/p2.js). 6 | * **3D Physics Engine**: A true 3D physics engine mode, based on the engine [Cannon](https://github.com/schteppe/cannon.js/). 7 | 8 | ## 2D Simple Physics 9 | 10 | This is probably the most common mode, and is appropriate for 2D games which don't need a real physics engine. Consider arcade games, fighting/brawler games, dungeon room games, platform, shooter, RPG, RTS, and runners. In all these cases, the game code doesn't require true physics. Instead, physical aspects such as position, velocity, and collisions are easily implemented using simple game logic. Even jumping from one platform to the next can be implemented as a sequence of height changes. 11 | 12 | In fact, many of these games are more fun to play using simplified (or pseudo) physics as they become more deterministic and predictable to the player. 13 | 14 | In order to use simple physics in Lance, you will need to initialize a `SimplePhysicsEngine` instance in the `GameEngine` constructor. In addition, all game object classes must extend the `DynamicObject` class. 15 | 16 | For example: 17 | ```javascript 18 | export default class Simple2DGameEngine extends GameEngine { 19 | 20 | constructor(options) { 21 | super(options); 22 | this.physicsEngine = new SimplePhysicsEngine({ 23 | gameEngine: this, 24 | collisions: { 25 | type: 'brute' 26 | } 27 | }); 28 | } 29 | } 30 | ``` 31 | 32 | See the sample game [Spaaace](https://github.com/lance-gg/spaaace) to see how this is done. 33 | 34 | ## 2D Real Physics 35 | 36 | Games which require true physics in 2D can use this mode. In order to use real physics in 2D, you will need to initialize an instance of `P2PhysicsEngine` in the `GameEngine` constructor. The underlying physics engine used in this case is P2. A very lightweight 2D physics engine. All game object classes must extend the `PhysicalObject2D` base class. 37 | 38 | See the sample game [Asteroids](https://github.com/lance-gg/asteroids) to see how this is done. Also, you may want to compare Spaaace and Asteroids to see how the game experience changes from pseudo-physics to real physics. 39 | 40 | 41 | ## 3D Real Physics 42 | 43 | 3D physics games are not common. In fact, many games which appear to be 3D physics games are in fact 2.5D pseudo-physics games. In those cases, the map is really a two dimensional map, but the objects are allowed to also have a height. The lance 3D physics mode uses the native javascript Cannon physics engine, written by Schteppe. 44 | 45 | To use 3D real physics, initialize an instance of `P2PhysicsEngine` in the `GameEngine` constructor. All game object classes must extend the `PhysicalObject3D` base class. 46 | 47 | See [SprocketLeague](https://github.com/lance-gg/sprocketleague) for a sample implementation. 48 | 49 | Next: {@tutorial guide_gameengine} 50 | -------------------------------------------------------------------------------- /docs/furtherreading.md: -------------------------------------------------------------------------------- 1 | The field of multiplayer game networking is vast, spans many years, game genres and software architectures. Here is a partial list of sources we've used while developing Lance. 2 | 3 | 4 | * **[Multiplayer Game Programming: Architecting Networked Game](https://www.amazon.com/Multiplayer-Game-Programming-Architecting-Networked-ebook/dp/B0189RXWJQ)** by Josh Glazer & Sanjay Madhav 5 | A very comprehensive book on all things multiplayer 6 | 7 | * **[Fast-Paced Multiplayer](http://www.gabrielgambetta.com/fast_paced_multiplayer.html)** by Gabriel Gambetta 8 | Describes client-side prediction and interpolation in depth 9 | 10 | * **[Gaffer on Games](http://gafferongames.com/networking-for-game-programmers/)** by Glenn Fiedler 11 | A series on Game Networking and a series on Networked Physics. 12 | 13 | * **[Synchronous RTS Engines and a Tale of Desyncs](https://blog.forrestthewoods.com/synchronous-rts-engines-and-a-tale-of-desyncs-9d8c3e48b2be#.tg3rrpjpr)** by Forrest Smith 14 | Articles discussing the multiplayer architecture of the RTS title *Supreme Commander* 15 | 16 | * **[Source Multiplayer Networking](https://developer.valvesoftware.com/wiki/Source_Multiplayer_Networking)** by Valve 17 | A thorough description of Valve's Source Engine multiplayer architecture 18 | 19 | * **[Timelines: simplifying the programming of lag compensation for the next generation of networked games 20 | Authors 21 | ](http://link.springer.com/article/10.1007/s00530-012-0271-3#Sec17)** by Cheryl Savery, T. C. Nicholas Graham 22 | In depth discussion of a timeline model for multiplayer games 23 | -------------------------------------------------------------------------------- /docs/guide_clientengine.md: -------------------------------------------------------------------------------- 1 | The Client Engine must run the game loop, just like the server engine. However it also needs to handle game syncs which arrive from the server. Also, the client engine executes a render loop, which can run at a different rate from the game loop. The render loop frequency is determined by the display hardware available, and so it may be faster or slower than the game loop frequency. 2 | 3 | A deeper exploration of the game world synchronization algorithms is addressed in separate chapters. The client will let the synchronization method take care of updating the client's game world in a way which appears visually smooth to the player. 4 | 5 | The client collects the player's inputs, such as mouse clicks, keyboard presses, and submits them to the server by calling `ClientEngine::sendInput()` method. 6 | 7 | Additionally, the client engine handles the following functions: 8 | 9 | 1. In the case of extrapolation mode, the client will not wait for the server to apply the inputs. Instead, it will apply the inputs locall as well, with a delay if so configured 10 | 11 | 2. Transmit user inputs to server 12 | 13 | 3. Handle changes in network delay, and step drift. If the server runs faster (or slower) than the client, then the server is executing steps faster (or slower) than the 14 | client. The client engine handles these situations by skipping steps or hurrying steps as required. Step adjustment is also necessary to recover from a spike in network traffic. 15 | 16 | The Client Engine has many options which must be chosen carefully. There is an option to auto-connect. If this is not set, then the client should at some point call the `connect()` method explicitly. 17 | Another important option is the sync-options. Here the client specifies the desired synchronization method and its options. The available synchronization methods and their options will be described in a separate chapter: {@tutorial guide_synchronization_methods}. 18 | 19 | See the {@link ClientEngine} implementation in the API Reference. 20 | 21 | Next: {@tutorial guide_renderer} 22 | -------------------------------------------------------------------------------- /docs/guide_gameengine.md: -------------------------------------------------------------------------------- 1 | The Game Engine contains the core game logic. It understands user inputs, and applies them to the game. It will fire missiles, hit aliens, boost spaceships, and apply magic spells. The primary game engine method is the step method, which progresses the game from step *N* to step *N+1*, given the inputs that arrived since step *N-1*. 2 | 3 | When the game engine step has completed, each game object must have it’s new current position updated, along with health, power, and any other game attributes your game derives. 4 | 5 | It is important to take note of the following architectural requirements: 6 | 7 | 1. The Game Engine may include some functions which will only run on the server, and these functions should be called directly by the server engine when necessary. For example, there may be a function to handle the score reporting. It is important to isolate server-only logic in a clear way. 8 | 9 | 2. On clients which use extrapolated synchronization, a given game step is often re-enacted following the arrival of a sync from the server. These "re-enactment" steps are flagged, and the game engine must ensure that the its step method does not have any side-effects when the "re-enactment" flag is set. 10 | 11 | Lastly, the game engine also provides a useful event emitter which reports on the progress of the game. A typical game will use these events to trigger any required logic with specific timing. Events include pre-step, post-step, player-joined, object-added, etc. Refer to the Events section of the API Reference for a complete list. 12 | 13 | See the {@link GameEngine} implementation in the API Reference. 14 | 15 | Next: {@tutorial guide_serverengine} 16 | -------------------------------------------------------------------------------- /docs/guide_gameworld.md: -------------------------------------------------------------------------------- 1 | The Game Engine includes a reference to the Game World, which consists of the game state, and a collection of Game Objects. The Game World is essentially the data which is liable to change from one game step to the next, and whose changes must be sent on every sync to every client. 2 | 3 | The game objects, as implemented in Lance, are instances of game object base classes. There are two supported base object classes: `DynamicObject` and `PhysicalObject`. DynamicObject is used for 2D games, with simplified physics. PhysicalObject is used for 3D games, with full-featured physics engine. For example, in a 2D game, all the game objects will be implemented as sub-classes of DynamicObject. These objects are serializable, and can be sent over a network. 4 | 5 | Take for example a game of 2D Breakout. The implementation will have three game object classes: the Paddle, the Ball, and the Brick. The game objects will include one instance of the Paddle class, one instance of a Ball class, and multiple instances of the Brick class. (That much is true anyways of the simple version of the game, where there is only a single ball, and a single paddle, and no shooting). The Paddle, the Ball, and the Brick are subclasses of DynamicObject. 6 | 7 | A game object sub-class will define its "netscheme", which is a dictionary of networked attributes for this object. The attributes listed in the netscheme will be those exact attributes which will be serialized by the server and broadcast to all clients on every sync. The DynamicObject base class only implements positional attributes, so additional attributes such as power, energy, or health, must be specified in the netscheme. 8 | 9 | Notes for the game developer: 10 | 11 | 1. PhysicalObject subclasses are "physical". In this case, each object instance has a velocity, orientation, and continuous-ranged position (i.e. the position is a floating-point value). 12 | 13 | 2. DynamicObject sub-classes may optionally be "pseudo-physical". In this case, the object’s position is a discrete value (i.e. an integer, or low-precision decimal), determined precisely by the count of movement inputs. 14 | 15 | 3. Some game objects may be user-controlled by a client, in that same client’s engine. These objects may not need to be synchronized to server sync data, since the client information is always up-to-date. We refer to these objects as "player-controlled objects". 16 | 17 | 4. Some game objects can be teleported (position translation) and others cannot. 18 | 19 | 5. Some game objects can have their velocity change suddenly and others cannot. 20 | 21 | It is important for a game developer to understand exactly what types of objects are being used and what properties they have, in order to properly configure the synchronization. 22 | 23 | ### Game Object Sub-Assets 24 | The game objects can keep references to one or more renderable assets which are managed by the Renderer. Also, a game object can keep references to one or more physical assets (bodies & particles) which are managed by a physics engine. For example, you may have a car game object. The car object keeps references to renderable assets such as the car's chassis, antenna, and wheels. In addition, the car object can keep reference to a single body in the physics object: a simple brick-shape. 25 | 26 | See the {@link DynamicObject} implementation in the API Reference. 27 | 28 | Next: {@tutorial guide_serialization} 29 | -------------------------------------------------------------------------------- /docs/guide_renderer.md: -------------------------------------------------------------------------------- 1 | The Renderer is a fully user-implemented component. In the architecture of an Lance multiplayer game, the renderer must render frames at the rate of the render-loop, as defined by the browser. Lance provides a full description of the game state in a list of game objects. The renderer must then scan the game objects and render them, based on position attributes, and any other object attributes which are prescribed by the game. 2 | 3 | See the {@link Renderer} implementation in the API Reference. 4 | 5 | Next: {@tutorial guide_gameworld} 6 | -------------------------------------------------------------------------------- /docs/guide_serialization.md: -------------------------------------------------------------------------------- 1 | As mentioned in previous chapters, the game world and its associated game objects must be packed on the server side, serialized, and broadcast to all clients at regular intervals. These game world update are called syncs. 2 | 3 | Serialization requires that each game object define those attributes which must be serialized on every sync. This attribute list is known as the game object’s netscheme. The larger a netscheme is, the more data will need to be transferred on every sync. Therefore care should be taken to minimize the amount of data in the netscheme. 4 | 5 | The serialization process allows for a hierarchy of objects. This means that the netscheme of one object may include another object which has its own netscheme. 6 | 7 | Synchronizations of the game state occurs at regular intervals, which are less frequent than the game steps. For example there may be 1 sync for every 10 steps. However 8 | this can be problematic for game events which occur at a very specific step. For example, a sync that describes the game evolution from step *N* to step *N+10* may 9 | need to report that a projectile was fired at step *N+5*. In order to handle this requirement the game must create "atomic" events. 10 | 11 | Next: {@tutorial guide_syncinterpolation} 12 | -------------------------------------------------------------------------------- /docs/guide_serverengine.md: -------------------------------------------------------------------------------- 1 | The Server Engine is a relatively simple component. As mentioned in the introduction, the server engine must do the following: (1) it must initialize the game, (2) it must accept connections from players over a socket, (3) it must execute the game loop, by calling the game engine’s `step()` method at a fixed interval, (4) it must apply the player inputs from all connections, and (5) it must broadcast regular updates to all the clients at a fixed interval. 2 | 3 | The game developer will extend this logic, by adding functions that log gameplay statistics, registering user activity, wins, losses, achievements. One of the extended methods is `GameEngine::processInput()` which applies a player's input to change the game state. 4 | 5 | Care must be taken on the following point: the server engine should not be used to contain game logic - that belongs in the game engine class. 6 | 7 | See the {@link ServerEngine} implementation in the API Reference. 8 | 9 | Next: {@tutorial guide_clientengine} 10 | -------------------------------------------------------------------------------- /docs/guide_syncextrapolation.md: -------------------------------------------------------------------------------- 1 | A different approach to synchronization is to extrapolate the game’s progress on the client, in the absence of sync data from the server. Once sync data does arrive from the server, a client must reconcile between the game state predicted on the client, and the game state that actually occurred on the server. 2 | 3 | ![extrapolation](https://cloud.githubusercontent.com/assets/3702763/20984522/4d5af6de-bcc9-11e6-86f4-116d3d5af237.PNG) 4 | 5 | In the diagram above, while the server is playing out step 1026, the clients are simultaneously rendering step 1030, which didn’t happen yet on the server. This implies that the clients used extrapolation to predict the future. 6 | 7 | Extrapolation is tunable, just like interpolation, by setting the number of steps that a client should be ahead of the server. Ideally, the client is just a little bit ahead, just enough so that when sync data arrives, the highest step information in the sync matches the current step played out on the client. 8 | 9 | An input which is sent by a client may arrive before the server has started playing the corresponding step. In this case the Server can be configured to wait for the input’s step. Since the server will process the input at a later step, the client may want to impose an artificial delay before actually enacting the input locally. This is another tunable parameter. 10 | 11 | When a sync arrives at the client, the client must reconcile the predicted game state with the actual (server) game state. This is performed as follows: 12 | 13 | 1. Client is at step N. 14 | 15 | 2. Roll back the state to the step described by the server in the sync, step M. 16 | 17 | 3. Re-enact all the steps between M and N by invoking the game engine’s step() method. 18 | 19 | 4. The game objects positions have shifted from the predicted value. Revert back to the client’s expected position, but remember the required delta between the client and the server. 20 | 21 | 5. Apply the delta so that the object on the client gradually bends towards the correct position as indicated by the server. 22 | 23 | 1. Not all the delta is applied. A "bending factor" must be specified, to indicate how much of the delta should be applied. 24 | 25 | 2. The delta is not applied in a single step. Rather, split the correction into incremental corrections that are applied until the next sync data arrives. 26 | 27 | Notes for the game developer: Multiple complications arise from the extrapolation method. 28 | 29 | 1. **Re-enactment**. Extrapolation re-enacts the game steps, and sometimes it may re-enact many steps. This means that the game engine must be capable of stepping through the same step multiple times. These are known as re-enactment steps. The step() method is passed an argument so that it can know when a step is a re-enactment step. 30 | 31 | 2. **Bending**. The client’s objects positions gradually bend towards the server’s positions. Velocity can also bend. The game designer must choose bending values carefully. Objects whose position can change suddenly (teleporting objects) will not bend well. Objects whose velocity can change suddenly (impulse) should not have velocity bending. 32 | 33 | 3. **Shadow Objects**. The client may create an object which does not yet exist on the server. For example, if a ship fires a missile, the client must render a missile, even though the missile object is yet to exist on the server. Until the true missile object will be created on the server, the client will model the missile with a shadow object. 34 | 35 | To select this synchronization method, the Client Engine syncOptions must be set to "extrapolate". The option "localObjBending" describes the amount of bending (0.0 to 1.0) for local objects, and "remoteObjBending" describes the amount of bending (0.0 to 1.0) for remote objects. See the client engine options as described in the API Reference: {@link ClientEngine}. 36 | 37 | Next: {@tutorial tutorials} 38 | -------------------------------------------------------------------------------- /docs/guide_syncinterpolation.md: -------------------------------------------------------------------------------- 1 | One way to synchronize the server and the clients is known as Interpolation. In this method, the clients render steps which previously played out on the server. The diagram below shows a typical sequence in an interpolated game. Time is advancing downwards. At the time when the server is playing out step 1026, the players are rendering the older step 1010. 2 | 3 | ![interpolation](https://cloud.githubusercontent.com/assets/3702763/20984519/47e3f5e8-bcc9-11e6-91a4-8a6af4977aa9.PNG) 4 | 5 | This approach has an important advantage, and an important disadvantage. The advantage is that each player has enough information available about the future in order to render visually smooth object motion. If a sync arrived at step 1010, and another at step 1020, then the object positions at the intermediate steps can be interpolated. The resulting visuals are smooth. 6 | 7 | The disadvantage of this approach is that when a player input arrives on a client, the consequences of this input will only kick in at a later time, potentially causing noticeable lag between a user’s input and the visual response. In the diagram above, an input that Player A submitted while Client A was at step 1010, will only get a visual response when Player A starts rendering objects at step 1029. 8 | 9 | The time delay between the server and the clients is a tunable parameter. Ideally one would choose the smallest delay possible such that there is still enough sync data to interpolate to. A temporary spike in network delays will cause the client to stop rendering and wait for new data. In order to recover from such spikes, the client should attempt to reduce the delay gradually. 10 | 11 | Notes for the game developer: 12 | 13 | 1. Not all game progress can be interpolated. Bouncing off walls, shooting, etc. are "atomic" steps which are not interpolatable. 14 | 15 | 2. Actions which are atomic must be explicitly marked as such, otherwise the game visuals will not make sense. 16 | 17 | 3. The delay in input response may mean that a given game is not a good match for interpolated synchronization. In that case, extrapolation should be considered. However, there are techniques which can make interpolation work nonetheless. Real-world input delay is about 200ms. There may be visual tricks that make this delay acceptable. For example if the input boosts a rocket, the game might show a visual boost light up before the rocket actually starts accelerating. 18 | 19 | 4. The game engine may not need to run on the client at all, if all the information required for rendering is available in the server sync. 20 | 21 | Next: {@tutorial guide_syncextrapolation} 22 | -------------------------------------------------------------------------------- /docs/guide_tuningdebugging.md: -------------------------------------------------------------------------------- 1 | In the prologue of this documentation, the complexity of multiplayer network games becomes evident. But that 2 | is small potatoes compared to debugging multiplayer network games. The debugging of a multiplayer game 3 | is made difficult by the following reasons: 4 | 5 | 1. A multiplayer network game consists of several non-symmetric hosts running non-symmetric software, separated by non-symmetric networks. 6 | 2. The problem may be very hard to reproduce, and exists only under certain race conditions. 7 | 3. The problem may be in the game mechanics as implemented in the game engine. For example, in the game of Pong, one might forget to check if the left paddle was hit *before* one checks if the left wall was hit (true story). 8 | 4. Then again, the problem may be in the configuration of the synchronization. For example, in the game of Pong, one might forget to disable velocity interpolation, and when the ball's velocity changes direction, the velocity interpolation causes strange visual effects (also, a true story). 9 | 5. Then again, the problem may be due to network spikes. 10 | 6. And finally, there is always the possibility that the game in question is not a good match for the architecture presented in this 11 | guide, and some of the assumptions in the game may need to be revisited in order to make the multiplayer experience viable with the proposed architecture. 12 | 13 | In all the cases above, the game developer sees the same symptom: an object jumps around strangely on the screen! 14 | 15 | At first this can be *very* frustrating, because the game developer does not know where to start. This document 16 | was written to help with the process of debugging multiplayer networked games using an organized process which 17 | help to narrow down the problem efficiently. 18 | 19 | ## The basic debugging process 20 | 21 | The following steps define an efficient debugging process: 22 | 23 | 1. Is the problem also on the Server, or just when the client tries to render the visuals? 24 | 1. If the problem is on the server, use server traces to see how the objects states change at each step. 25 | 2. If the problem is only on the client, is the problem related to Synchronization? 26 | 2. Is the problem caused by unusual network conditions? 27 | 28 | 29 | ## Server or Client 30 | 31 | The problem is always first observed on the client. To determine if the problem 32 | exists on the server as well, the easiest approach is to use the Reflect synchronization 33 | strategy. The Reflect method is not really a synchronization strategy but rather a degenerate 34 | synchronization method which simply shows the server sync data as it arrives. 35 | 36 | To run a game in reflect mode, simply use the URL query string option: 37 | 38 | ``` 39 | http://127.0.0.1:3000/?sync=reflect 40 | ``` 41 | 42 | The problem can also occur in such a way that it usually happens in between syncs. 43 | In this case, one can configure the server to increase the update rate so that 44 | it approaches the client's render rate. 45 | 46 | The ServerEngine has options to set the step rate and the sync update rate: 47 | ```javascript 48 | const serverEngine = new MyServerEngine(io, gameEngine, { 49 | updateRate: 6, 50 | stepRate: 60, 51 | }); 52 | ``` 53 | 54 | Lastly, an important technique is to test the game on a local network (i.e. run server 55 | and clients on same host) to make sure that network delay is not a factor. 56 | 57 | ## Check Server Traces 58 | 59 | To enable server traces, set the trace level when the game engine is created: 60 | ```javascript 61 | const gameEngine = new MyGameEngine({ traceLevel: 1 }); 62 | ``` 63 | 64 | See the Trace class for trace constants. The trace output file is called `server.trace` 65 | and it will show trace data which looks as follows: 66 | 67 | ``` 68 | [2016-12-04T22:16:15.136Z]719>game engine processing input[10052] from playerId 1 69 | [2016-12-04T22:16:15.153Z]720>game engine processing input[10053] from playerId 1 70 | [2016-12-04T22:16:15.153Z]720>========== sending world update 720 ========== 71 | [2016-12-04T22:16:15.170Z]721>game engine processing input[10054] from playerId 1 72 | [2016-12-04T22:16:15.170Z]721>========== destroying object DynamicObject[2] position(273.854, 304.23, NaN) velocity(-4.529, -2.119, NaN) angle205 ========== 73 | ``` 74 | 75 | Each line starts with a timestamp, and a step number. The example above shows 76 | steps 719-720. At the lowest trace level, the service will show each object's 77 | attributes, including position, at the end of every step. 78 | 79 | To make debugging easier, you may want to implement your object's `toString()` method, 80 | to show a useful representation of the object. This will be *very useful* in the long run. 81 | 82 | ## Check Client Traces 83 | 84 | Enabling client traces can be done from the URL's query string, using the *traceLevel* 85 | parameter. Here is an example: 86 | ``` 87 | http://127.0.0.1:3000/?sync=reflect&traceLevel=0 88 | ``` 89 | 90 | Since there are multiple clients, there are also multiple trace files, one for each client. The trace files are named `client..trace`. 91 | 92 | ### Client Interpolation 93 | 94 | Note that in interpolation, the client actually plays a step which is older than the current 95 | step. Ensure you check the trace to see which step the interpolation is targeting. 96 | Interpolation runs the client's game engine in passive mode, meaning that the game engine 97 | on the client will not execute the physics steps. 98 | 99 | ### Client Extrapolation 100 | 101 | For extrapolation synchronization, the client will re-enact the required history of steps 102 | each time a new sync is received. This is traced in low-level detail. To debug 103 | problems related to bending, look at the position attributes of the relevant object 104 | just before the sync was received, and the result of the re-enactment before bending 105 | was applied, and the final object position after bending was applied. 106 | -------------------------------------------------------------------------------- /docs/introduction.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/lance-gg/lance.svg?branch=master)](https://travis-ci.org/lance-gg/lance) [![Inline docs](http://inch-ci.org/github/lance-gg/lance.svg?branch=develop)](http://inch-ci.org/github/lance-gg/lance) 2 | [![Slack](http://slack.lance.gg/badge.svg)](http://slack.lance.gg) 3 | 4 | Lance logo 5 | 6 | # [Lance](http://lance.gg) is a real-time multiplayer game server 7 | 8 | Lance provides an extendible Node.JS based server, on which game logic runs, as well as a client-side library 9 | which synchronizes the client's game state with the server game state. In order 10 | to provide a smooth visual experience for each connected client, Lance implements 11 | efficient networking methods, position interpolation and extrapolation, user input 12 | coordination, shadow objects, physics and pseudo-physical movement, automatic 13 | handling of network spikes. 14 | 15 | Lance aims to optimize the player's visual experience, while providing 16 | a simple development model which is highly configurable and easy to analyze 17 | and debug. 18 | 19 | ## See it in action 20 | Check out the official demo: [Spaaace](http://spaaace.herokuapp.com) 21 | 22 | ![spaaace](https://cloud.githubusercontent.com/assets/3951311/21784604/ffc2d282-d6c4-11e6-97f0-0ada12c4fab7.gif) 23 | 24 | 25 | 26 | ## Features: 27 | 28 | * Focus on writing your game. Lance takes care of the netcode 29 | * Can support any type of game or genre 30 | * Optimized networking 31 | * TCP via websockets 32 | * Communication is packed and serialized into binary 33 | * Automatic handling of network spikes with step correction 34 | * Intelligent synchronization strategies for lag handling 35 | * Extrapolation (client side prediction) with step re-enactment, or: 36 | * Interpolation for optimal object motion 37 | * Tools for debugging and tracing 38 | 39 | ## That's so neat! Where do I start? 40 | 41 | To get a bird's eye view on what's Lance all about and how to use it to make a multiplayer game, we recommend reading the guide: {@tutorial overview_architecture} 42 | 43 | If you're more of the learning-by-doing type, you can start with the first tutorial, {@tutorial MyFirstGame} which contains step-by-step instructions on how to implement a networked version of this classic game. 44 | 45 | ## Join the fun! 46 | 47 | We are working hard to build an active, positive and inclusive {@tutorial introduction_community} 48 | 49 | ## Need help? 50 | 51 | If you're not exactly sure how to do something, [Stack Overflow](http://stackoverflow.com/questions/tagged/lance) is your friend. 52 | 53 | If you've encountered a bug and it's not already in the [issues page](https://github.com/lance-gg/lance/issues), open a new issue. 54 | 55 | 56 | ## Built something cool with Lance? 57 | 58 | Please [Let us know](http://www.twitter.com/opherv)! We'd love to play it, and feature it on the [Lance homepage](http://lance.gg). 59 | -------------------------------------------------------------------------------- /docs/introduction_community.md: -------------------------------------------------------------------------------- 1 | We make great efforts to maintain an active, positive, friendly and inclusive community around Lance and game development. 2 | 3 | ### Starting out 4 | 5 | To get a bird's eye view on what's Lance all about and how to use it to make a multiplayer game, we recommend reading the guide: {@tutorial architecture} 6 | 7 | If you're more of the learning-by-doing type, you can start with the first tutorial, {@tutorial MyFirstGame} which contains step-by-step instructions on how to implement a networked version of this classic game. 8 | 9 | ### Joining the discussion 10 | You're welcome to join the [Lance Slack](http://slack.lance.gg) to hang out and engage in active discussion with the project maintainers and fellow game developers. 11 | 12 | ### Keeping in touch 13 | 14 | Follow [LanceGG_](http://twitter.com/LanceGG_) on Twitter to hear the latest and see what others are doing with Lance 15 | 16 | ### Getting help 17 | Feel free to ask anything on [Stack Overflow](http://stackoverflow.com/questions/tagged/lance). We keep an eye out for questions! 18 | 19 | ### Helping out 20 | 21 | Lance is an open source project, and as such we welcome any contribution. 22 | 23 | * Spreading the word, posting or tweeting about Lance in social media using the (https://twitter.com/lancegg_)[@lancegg_ tag]. The larger the community, the more fun we have and the faster development can move! 24 | 25 | * Improving the documentation: fixing typos, expanding, clarifying or writing new articles 26 | 27 | * Fixing bugs, or adding new functionality. A good place to start would be issues on Github with a status of **["PR Welcome"](https://github.com/lance-gg/lance/issues?q=is%3Aissue+is%3Aopen+label%3A%22PR+welcome%22)**. Make sure to open pull requests against the develop branch. 28 | -------------------------------------------------------------------------------- /docs/introduction_faq.md: -------------------------------------------------------------------------------- 1 | 2 | ## Lance Basics 3 | 4 | This list of questions is derived mostly from recurring questions and discussions on our slack group. The list is meant as a high-level starting point, but you are encouraged to join in the conversation. Auto-join the [Lance Slack group with this link](http://slack.lance.gg). 5 | 6 | ### 1. What is Lance? 7 | 8 | Lance is an open-source project which makes it easier to write multiplayer physics networked games in JavaScript. Lance implements some components of the multiplayer game, so that a game developer can focus on the game itself. The components which Lance implements are networking, client-side prediction of object positions, extrapolation, 3D/2D networked physics, and more. 9 | 10 | ### 2. What is the basic structure of an Lance game? 11 | Lance uses a client-server model. There is a single authoritative server, and multiple clients in each networked game. A game based on Lance will have server code, client code, and common code. The game logic is common code, and can run on the authoritative server as well as on every client. 12 | 13 | ### 3. What is client-side prediction? 14 | Client-side prediction is the logic which runs on every client, and extrapolates the state of the game. In the Lance implementation, client-side prediction uses physical attributes such as position, velocity, orientation, and angular velocity to predict the game state on each client. This provides a smooth playing experience for each client, hiding the side-effects of network lag and network spikes. 15 | 16 | ### 4. What is bending? 17 | Bending is the process of gradually correcting the positions and orientations of game objects on the client, which have drifted from their authoritative positions and orientations on the server. Bending is applied incrementally on each renderer draw event. 18 | 19 | ## Lance Usage 20 | 21 | ### 1. Does Lance scale? 22 | The answer to this question highly depends on the game type. For games where a few players join separate rooms or “zones” within the game, an Lance server can be used for each room. This is the simple case. 23 | 24 | In the case of large worlds, where hundreds of players join a single space, Lance needs to separate the single space into subspaces such that each Lance server is responsible for one of the subspaces. This feature has not been implemented yet. If you’re interested in this feature please upvote the following [feature request](https://github.com/lance-gg/lance/issues/30). 25 | 26 | ### 2. Does Lance preserve state? 27 | Since Lance is designed for real-time calculation of position, running typically at 60 steps per second, state-preservation is not a primary concern. However, this has been requested in the past. If you’re interested in this feature please upvote the following [feature request](https://github.com/lance-gg/lance/issues/31). 28 | 29 | ### 3. How do I implement chat services with Lance? 30 | It’s preferred that non-real time communication is handled by a different server than the one which runs the game state, therefore chat services are outside the scope of Lance. The preferred approach is to use an existing chat service together with Lance in your game. There is a feature request to provide a sample game which demonstrates Lance working together with a third-party chat service. If you’re interested in this feature please upvote the following [feature request](https://github.com/lance-gg/lance/issues/32). 31 | 32 | ### 4. What is in the Lance Roadmap? 33 | See the Roadmap: {@tutorial introduction_roadmap}. Some of the major planned features include UDP support, and Visual Debugging. 34 | 35 | ## Lance Packaging / Transpiling 36 | 37 | ### 1. Can I use Lance with babel? 38 | Yes! Lance exports es6 code, so you will need to include it in your babel configuration so that it gets transpiled with the rest of your code. This can be done using the include option in your .babelrc or webpack.config file. See the sample games provided in the Github Lance-gg organization. 39 | -------------------------------------------------------------------------------- /docs/introduction_prologue.md: -------------------------------------------------------------------------------- 1 | ## ...or why making multiplayer games is so hard 2 | 3 | If you've ever made a single-player video game, you already know that regardless of the platform, genre or mechanic - developing a game is a huge undertaking. There are so many challenges to overcome including narrative, game design, graphics, sound, AI, performance and many others. Even if you get all of those right - the game might still not be fun. A well-executed game that's no fun is not even worth playing, despite all the effort put into it. 4 | 5 | Making a multiplayer game is even harder than a single-player one. It takes all the difficulties already encountered while developing a single-player game and amplifies them by orders of magnitude. 6 | 7 | Your major obstacle? The speed of light. 8 | 9 | Well, not exactly, but the speed of light is THE upper bound for the fastest speed anything can physically move at, and that includes data between computers. Despite the contrary belief, the speed of data isn't instantaneous. 10 | 11 | Let's say a game server is in Melbourne, Australia, and you, the player, is connecting from Tel Aviv, Israel. The distance between the two cities is 13,759 Kilometers, or 13,759,000 meters. 12 | The speed of light is 299,792,458 meters per second. Assume for a second that there are no computers, networks or routers in the way - and that data moves at the speed of light. An update travelling from the server in Australia to the in Israel will take 45 milliseconds to reach its destination. So if you hit a key to move your car in-game, that command gets sent to the server in Melbourne and than the result of that command is sent back to your computer in Tel Aviv. The roundtrip alone is 90ms! That is without any other delays generated by network, hardware or software. 13 | 14 | ![image](https://cloud.githubusercontent.com/assets/3951311/21779276/99ea40ea-d6af-11e6-960c-aa4bddf6f0e2.png) 15 | 16 | 17 | What does this mean though? It means that lag, or latency, is not a "bug" or a "network condition". It is a constant, ever-present and inherent to the system. A player can never know the true current state of the game, because it always gets updates in delay. The server can't know the true state of the game, because all input from clients will arrive delayed. 18 | 19 | #### Real-time multiplayer games aren't actually real-time. They only offer the *illusion* that everyone is playing the same game, in the same world, at the same time. 20 | 21 | Dealing with all the issues associated from networking games is a daunting task that requires knowledge, persistence and constant vigilance. 22 | 23 | But you're a game designer - not a network engineer and that's exactly why we built Lance. We want to free game developers to develop games, not network synchronisation code. We aim to build a thriving community around Lance and hope to see many more multiplayer games out there that truly connect people. That's what multiplayer games are all about 24 | 25 | Opher & Gary 26 | 27 | 28 | 29 | 30 | Next: {@tutorial overview_architecture} 31 | -------------------------------------------------------------------------------- /docs/introduction_roadmap.md: -------------------------------------------------------------------------------- 1 | 2 | ## Current Release 3 | 4 | ### r4.0.0 - *Noether* - May 2019 5 | 6 | Release 4.0.0 is now available for npm-installing. It provides mostly infrastructure improvements, which were required for some basic feature implementation. 7 | 8 | * adds rollup-js for native modules. This will allow tree-shaking on the game side, making the client code smaller. It also enables TypeScript exports, which is the most popular feature request. 9 | * one-page games. There are several one-page game examples in the examples repo [tinygames](https://github.com/lance-gg/tinygames). 10 | * support for rooms (game object name-spacing). Using new methods in the ServerEngine: `createRoom()`, `assignObjectToRoom()`, and `assignPlayerToRoom()` 11 | * upgrade to babel 7.0 12 | 13 | ## Future Releases 14 | 15 | All releases listed here, along with their planned release dates and their listed contents, are a statement of intentions, and are provided with no guarantee whatsoever. Lance is an open-source project and as such depends on the available time of its developers. The roadmap is subject to change at any time. 16 | 17 | ### r5.0.0 - *Maxwell* - December 2019 18 | 19 | ## Roadmap Candidates 20 | 21 | There are many candidates, please upvote your favourite at the corresponding github issue-request. 22 | 23 | * entity-component-system redesign 24 | * typescript 25 | * UDP via WebRTC 26 | * Electron support 27 | * MatterJS support 28 | * Automated Cloud Deploy system for Lance game servers 29 | * Proper testing framework 30 | * Better debug tools: 31 | * Parses recorded logs 32 | * shows GUI that allow scrubbing through time to see values over time of client state, server state, interpolated/extrapolated state 33 | 34 | ## Past Releases 35 | 36 | ### r3.0.0 - *Majorana* - July 2018 37 | * 2D engine support - P2 38 | * New sample game Asteroids 39 | * Interpolation mode 40 | * Generic bending code 41 | 42 | ### r2.0.1 - *Spinor* - February 2018 43 | * ES6 Modules support 44 | * Renderer-controller game loop. The game step delta is tuned to the render draw time 45 | * Full-sync support, providing full data sync to new connections 46 | * Game Object re-architecture: Renderer objects and Physics objects are sub-objects of the Game Object 47 | * New KeyboardControls class 48 | * Smart sync, syncing only changed objects 49 | 50 | ### r1.0.1 - *Tensor* - March 2017 51 | * Full 3D support 52 | * Pluggable Physics Engine support: cannon.js 53 | * Demonstrate A-Frame support 54 | * Refactor: game objects contain render and physics sub-objects 55 | 56 | ### r0.9.1 - “Incheon Phase 2” External Beta Release - January 2017 57 | 58 | * Games: Pong, Spaaace 59 | * Sync Strategies: Extrapolation, Interpolation 60 | * Complete Documentation 61 | * Spaaace - Online Desktop/Mobile Live Demo 62 | * Refactor event names (remove dot) to make compatible with jsdoc 63 | 64 | ### r0.2.0 - “Incheon Phase 1” Internal Release - December 2016 65 | 66 | * Games: Pong 67 | * Sync Strategies: Extrapolation, Interpolation 68 | * Refactor Renderer 69 | * Lance.gg web site 70 | * Boilerplate Game Repository 71 | * Documentation started. Tutorials: MyFirstGame, Spaceships 72 | 73 | 74 | ### r0.1.0 - "Incheon" October 2016 75 | 76 | * First working model 77 | * Games: Spaaace, Sumo 78 | * Sync Strategies: ServerSync, Interpolation 79 | -------------------------------------------------------------------------------- /docs/overview_architecture.md: -------------------------------------------------------------------------------- 1 | 2 | The architecture of a multiplayer game must meet the requirements and challenges presented in the {@tutorial prologue} "why is making a multiplayer game so hard". The architecture cannot wish away these facts: that network delays exist, the delay durations are not consistent or predictable, that players can suddenly disconnect, or that client code cannot be trusted. 3 | 4 | These requirements lead to some basic architectural principles: 5 | 6 | 1. Each game instance will have a single server where the actual game progress and decisions are played out. 7 | 8 | 2. A client will always be out-of-sync with the server, and will need to be able to adjust itself to server decisions. 9 | 10 | 3. In order to provide a smooth playing experience, a client will need to implement either some predictive calculation (extrapolation), or otherwise present a somewhat out-of-date state to the end user (interpolation). 11 | 12 | The main components of a networked game are: 13 | 14 | * The **clients**. Represented in Lance by multiple instances of the **ClientEngine** class. Clients collect inputs from the player and send them to the server. 15 | 16 | * The **server**. Represented in Lance by a singleton instance of the **ServerEngine** class. The server handles the user inputs, and sends updates to all clients. 17 | 18 | * The **game logic**. Represented in Lance by the **GameEngine** class. 19 | 20 | * The **game world**, which includes multiple **game objects**. The Lance **GameObject** is the base class for all kinds of game objects. 21 | 22 | * The **renderer**. A component which draws the game visuals on every iteration of the render loop. In Lance this is represented by the **Renderer** class. 23 | 24 | * **Synchronization**. Lance provides several ways to synchronize between the server and the clients. The game developer must configure which synchronization method works best for their specific game and use case. 25 | 26 | As you develop your game, you will need to implement your own extensions (sub-classes) of the classes above. The core game logic will be implemented in your extension of GameEngine. 27 | 28 | The following diagram shows how these components connect in the overall architecture: 29 | 30 | ![architecture](https://user-images.githubusercontent.com/3702763/36915030-e4612af8-1e57-11e8-81d5-fca3855d6fe5.jpg) 31 | 32 | ## The Game as a Sequence of Steps 33 | 34 | The basic flow of a game can be seen as a sequence of *game steps*. This is a basic concept which is true 35 | for game development generally, and the concept works well for networked games as well. During a single step, the 36 | game progresses from time *T* to time *T + δt*. The game engine will have to determine the state of the game 37 | at time *T + δt* by applying physics, taking account of new user inputs, and applying the game mechanics logic. 38 | 39 | In the context of multiplayer, networked games, the steps will be executed both on the server and the client. Each step is numbered, and depending on the synchronization strategy, clients may be executing a given step before the corresponding server information has arrived at the client (i.e. extrapolation) or after (i.e. interpolation). Ideally, a given step *N* represents the same point in game play on both the server and the client. 40 | 41 | The core game logic is implemented in the game engine, so a game step is simply a call to the game engine’s `GameEngine::step()` method. 42 | 43 | ## Server Flow 44 | 45 | The server logic is implemented by the server engine, which must do the following: (1) it must initialize the game, (2) it must accept connections from players over a socket, (3) it must execute the game loop, by calling the game engine’s `GameEngine::step()` method at a fixed interval, and (4) it must broadcast regular updates to all the clients at a fixed interval. 46 | 47 | The server engine schedules a step function to be called at a regular interval. The flow is: 48 | 49 | * ServerEngine - *start of a single server step* 50 | 51 | * GameEngine - read and process any inputs that arrived from clients since the previous step. The inputs are handled by the `GameEngine::processInput()` method. 52 | 53 | * GameEngine - *start of a single game step* 54 | 55 | * PhysicsEngine - handle physics step 56 | 57 | * If it is time to broadcast a new sync 58 | 59 | * for each player: transmit a "world update" 60 | 61 | ## Client Flow 62 | 63 | The client flow is more complicated than the server, for two reasons. First it must listen to syncs which have arrived from the server, and reconcile the data with its own game state. Second, it must invoke the renderer to draw the game state. 64 | 65 | * ClientEngine - *start of a single client step* 66 | 67 | * check inbound messages / syncs 68 | 69 | * capture user inputs that have occurred since previous step. Inputs are sent to the server by calling the method `ClientEngine::sendInput()`. 70 | 71 | * transmit user inputs to server 72 | 73 | * apply user inputs locally 74 | 75 | 76 | 77 | * ClientEngine - *start of a single render step* 78 | 79 | * Renderer - draw event 80 | 81 | * GameEngine - *start of a single game step* - may need to be executed zero or more times, depending on the number of steps which should have taken place since the last render draw event 82 | 83 | * PhysicsEngine - handle physics step 84 | 85 | Next: {@tutorial choosing_a_physics_engine} 86 | -------------------------------------------------------------------------------- /jsdoc.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "opts": { 3 | "destination": "./docs_out/", 4 | "encoding": "utf8", 5 | "readme": "docs/introduction.md", 6 | "template": "node_modules/lance-docs-template", 7 | "tutorials": "./docs" 8 | }, 9 | "sourceType": "module", 10 | "source": { 11 | "include": [ 12 | "src/ServerEngine.js", 13 | "src/ClientEngine.js", 14 | "src/GameEngine.js", 15 | "src/GameWorld.js", 16 | "src/controls/KeyboardControls.js", 17 | "src/physics/SimplePhysicsEngine.js", 18 | "src/physics/CannonPhysicsEngine.js", 19 | "src/serialize/BaseTypes.js", 20 | "src/serialize/GameObject.js", 21 | "src/serialize/DynamicObject.js", 22 | "src/serialize/Serializer.js", 23 | "src/serialize/Serializable.js", 24 | "src/serialize/PhysicalObject2D.js", 25 | "src/serialize/PhysicalObject3D.js", 26 | "src/serialize/TwoVector.js", 27 | "src/serialize/ThreeVector.js", 28 | "src/serialize/Quaternion.js", 29 | "src/render/Renderer.js", 30 | "src/render/AFrameRenderer.js", 31 | "src/lib/Trace.js" 32 | ] 33 | }, 34 | "plugins": [ 35 | "plugins/markdown" 36 | ], 37 | "templates": { 38 | "systemName" : "Lance", 39 | "copyright" : "Lance Copyright © 2017 The contributors to the Lance project.", 40 | "includeDate" : true, 41 | "navType" : "vertical", 42 | "theme" : "spacelab", 43 | "linenums" : false, 44 | "collapseSymbols" : true, 45 | "inverseNav" : false, 46 | "outputSourceFiles" : false, 47 | "outputSourcePath" : false, 48 | "dateFormat" : "ddd MMM Do YYYY", 49 | "syntaxTheme" : "default", 50 | "sort" : false, 51 | "search" : true 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lance-gg", 3 | "version": "5.0.2", 4 | "description": "A Node.js based real-time multiplayer game server", 5 | "type": "module", 6 | "main": "dist/server/lance-gg.js", 7 | "browser": "dist/client/lance-gg.js", 8 | "types": "dist/types/lance-gg.d.ts", 9 | "scripts": { 10 | "docs": "jsdoc -c jsdoc.conf.json", 11 | "build": "rollup --config rollup.config.js", 12 | "test-all": "mocha test/EndToEnd/", 13 | "test-serializer": "mocha ./test/serializer/", 14 | "test": "mocha ./test/serializer/" 15 | }, 16 | "keywords": [ 17 | "pixijs", 18 | "pixi", 19 | "socket", 20 | "canvas", 21 | "WebGL", 22 | "HTML5", 23 | "AFrame", 24 | "physics", 25 | "engine", 26 | "game", 27 | "realtime", 28 | "multiplayer" 29 | ], 30 | "repository": { 31 | "type": "git", 32 | "url": "git://github.com/lance-gg/lance.git" 33 | }, 34 | "dependencies": { 35 | "@types/cannon": "^0.1.12", 36 | "@types/event-emitter": "^0.3.5", 37 | "@types/p2": "^0.7.44", 38 | "@types/three": "^0.163.0", 39 | "bufferutil": "^4.0.1", 40 | "cannon": "^0.6.2", 41 | "event-emitter": "^0.3.5", 42 | "jsdoc": "^4.0.2", 43 | "mkdirp": "^0.5.1", 44 | "p2": "^0.7.1", 45 | "socket.io": "^4.7.5", 46 | "socket.io-client": "^4.7.5", 47 | "tslib": "^2.6.2", 48 | "typescript": "next", 49 | "utf-8-validate": "^5.0.2" 50 | }, 51 | "files": [ 52 | "src", 53 | "dist" 54 | ], 55 | "author": "Opher Vishnia", 56 | "contributors": [ 57 | { 58 | "name": "Opher Vishnia" 59 | }, 60 | { 61 | "name": "Gary Weiss" 62 | } 63 | ], 64 | "license": "Apache-2.0", 65 | "bugs": { 66 | "url": "https://github.com/lance-gg/lance/issues" 67 | }, 68 | "homepage": "https://github.com/lance-gg/lance#readme", 69 | "devDependencies": { 70 | "@rollup/plugin-commonjs": "^25.0.7", 71 | "@rollup/plugin-json": "^6.1.0", 72 | "@rollup/plugin-node-resolve": "^15.2.3", 73 | "@rollup/plugin-typescript": "^11.1.6", 74 | "chai": "^3.5.0", 75 | "eslint": "^8.28.0", 76 | "eslint-config-google": "^0.6.0", 77 | "express": "^4.14.0", 78 | "lance-docs-template": "github:lance-gg/lance-docs-template#semver:^1.0.1", 79 | "mocha": "^10.1.0", 80 | "query-string": "^4.3.1", 81 | "rollup": "^4.14.2", 82 | "rollup-plugin-dts": "^6.1.0", 83 | "should": "^11.0.0" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript'; 2 | import pjson from '@rollup/plugin-json'; 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | import resolve from '@rollup/plugin-node-resolve'; 5 | import dts from 'rollup-plugin-dts'; 6 | 7 | export default [ 8 | { 9 | // server build non-transpiled with es2015 modules 10 | input: 'src/package/serverExports.ts', 11 | external: ['fs', 'bufferutil', 'utf-8-validate'], 12 | output: { file: 'dist/server/lance-gg.js', format: 'esm', name: 'Server' }, 13 | plugins: [resolve(), typescript(), pjson(), commonjs({ include: 'node_modules/**' })] 14 | }, 15 | { 16 | // client build non-transpiled with es2015 modules 17 | input: 'src/package/clientExports.ts', 18 | output: { file: 'dist/client/lance-gg.js', format: 'esm', name: 'Client' }, 19 | plugins: [resolve({ browser: true, preferBuiltins: false }), typescript(), pjson(), commonjs({ include: 'node_modules/**' })] 20 | }, 21 | { 22 | // types only 23 | input: ['src/package/allExports.ts'], 24 | output: { file: "dist/types/lance-gg.d.ts", format: "es" }, 25 | plugins: [dts()] 26 | } 27 | ] 28 | -------------------------------------------------------------------------------- /src/GameWorld.ts: -------------------------------------------------------------------------------- 1 | import { GameObject } from "./serialize/GameObject.js"; 2 | 3 | interface ObjectQuery { 4 | id?: number; 5 | playerId?: number; 6 | instanceType?: typeof GameObject; 7 | components?: string[]; 8 | returnSingle?: boolean; 9 | } 10 | 11 | /** 12 | * This class implements a singleton game world instance, created by Lance. 13 | * It represents an instance of the game world, and includes all the game objects. 14 | * It is the state of the game. 15 | */ 16 | class GameWorld { 17 | 18 | public objects: { [key: number]: GameObject } 19 | public stepCount: number; 20 | public playerCount: number; 21 | public idCount: number; 22 | 23 | /** 24 | * Constructor of the World instance. Invoked by Lance on startup. 25 | * 26 | * @hideconstructor 27 | */ 28 | constructor() { 29 | this.stepCount = 0; 30 | this.objects = {}; 31 | this.playerCount = 0; 32 | this.idCount = 0; 33 | } 34 | 35 | /** 36 | * Gets a new, fresh and unused id that can be used for a new object 37 | * @private 38 | * @return {Number} the new id 39 | */ 40 | getNewId(): number { 41 | let possibleId = this.idCount; 42 | 43 | // find a free id 44 | while (possibleId in this.objects) 45 | possibleId++; 46 | 47 | this.idCount = possibleId + 1; 48 | return possibleId; 49 | } 50 | 51 | queryOneObject(query: ObjectQuery): GameObject | null { 52 | let objs = this.queryObjects(query); 53 | return objs.length > 0 ? objs[0] : null; 54 | } 55 | 56 | /** 57 | * Returns all the game world objects which match a criteria 58 | * @param {Object} query The query object 59 | * @param {Object} [query.id] object id 60 | * @param {Object} [query.playerId] player id 61 | * @param {Class} [query.instanceType] matches whether `object instanceof instanceType` 62 | * @param {Array} [query.components] An array of component names 63 | * @return {Array} All game objects which match all the query parameters 64 | */ 65 | queryObjects(query: ObjectQuery): GameObject[] { 66 | let queriedObjects: GameObject[] = []; 67 | 68 | // todo this is currently a somewhat inefficient implementation for API testing purposes. 69 | // It should be implemented with cached dictionaries like in nano-ecs 70 | this.forEachObject((id: number, object: GameObject) => { 71 | let conditions: boolean[] = []; 72 | 73 | // object id condition 74 | conditions.push(!('id' in query) || query.id !== null && object.id === query.id); 75 | 76 | // player id condition 77 | conditions.push(!('playerId' in query) || query.playerId !== null && object.playerId === query.playerId); 78 | 79 | // instance type conditio 80 | conditions.push(!('instanceType' in query) || query.instanceType !== null && object instanceof query.instanceType); 81 | 82 | // components conditions 83 | if ('components' in query) { 84 | query.components.forEach(componentClass => { 85 | conditions.push(object.hasComponent(componentClass)); 86 | }); 87 | } 88 | 89 | // all conditions are true, object is qualified for the query 90 | if (conditions.every(value => value)) { 91 | queriedObjects.push(object); 92 | if (query.returnSingle) return false; 93 | } 94 | }); 95 | 96 | return queriedObjects; 97 | } 98 | 99 | /** 100 | * Returns The first game object encountered which matches a criteria. 101 | * Syntactic sugar for {@link queryObjects} with `returnSingle: true` 102 | * @param {Object} query See queryObjects 103 | * @return {Object} The game object, if found 104 | */ 105 | queryObject(query) { 106 | return this.queryObjects(Object.assign(query, { 107 | returnSingle: true 108 | })); 109 | } 110 | 111 | /** 112 | * Add an object to the game world 113 | * @private 114 | * @param {Object} object object to add 115 | */ 116 | addObject(object) { 117 | this.objects[object.id] = object; 118 | } 119 | 120 | /** 121 | * Remove an object from the game world 122 | * @private 123 | * @param {number} id id of the object to remove 124 | */ 125 | removeObject(id) { 126 | delete this.objects[id]; 127 | } 128 | 129 | /** 130 | * World object iterator. 131 | * Invoke callback(objId, obj) for each object 132 | * 133 | * @param {function} callback function receives id and object. If callback returns false, the iteration will cease 134 | */ 135 | forEachObject(callback) { 136 | for (let id of Object.keys(this.objects)) { 137 | let returnValue = callback(id, this.objects[id]); // TODO: the key should be Number(id) 138 | if (returnValue === false) break; 139 | } 140 | } 141 | 142 | } 143 | 144 | export { GameWorld, ObjectQuery } 145 | -------------------------------------------------------------------------------- /src/Synchronizer.ts: -------------------------------------------------------------------------------- 1 | import { ClientEngine } from './ClientEngine.js'; 2 | import { SyncStrategy, SyncStrategyOptions } from './syncStrategies/SyncStrategy.js'; 3 | 4 | class Synchronizer { 5 | 6 | public syncStrategy: SyncStrategy; 7 | private clientEngine: ClientEngine; 8 | 9 | // create a synchronizer instance 10 | constructor(clientEngine: ClientEngine, syncStrategy: SyncStrategy) { 11 | this.clientEngine = clientEngine; 12 | this.syncStrategy = syncStrategy; 13 | } 14 | } 15 | 16 | export { Synchronizer} -------------------------------------------------------------------------------- /src/controls/KeyboardControls.ts: -------------------------------------------------------------------------------- 1 | import { ClientEngine } from '../ClientEngine.js' 2 | import { GameEngine } from '../GameEngine.js'; 3 | 4 | type KeyOptions = { 5 | repeat: boolean 6 | } 7 | 8 | type KeyAction = { 9 | actionName: string, 10 | options: KeyOptions, 11 | parameters: any 12 | } 13 | 14 | type KeyState = { 15 | isDown: boolean, 16 | count: number 17 | } 18 | 19 | /** 20 | * This class allows easy usage of device keyboard controls. Use the method {@link KeyboardControls#bindKey} to 21 | * generate events whenever a key is pressed. 22 | * 23 | * @example 24 | * // in the ClientEngine constructor 25 | * this.controls = new KeyboardControls(this); 26 | * this.controls.bindKey('left', 'left', { repeat: true } ); 27 | * this.controls.bindKey('right', 'right', { repeat: true } ); 28 | * this.controls.bindKey('space', 'space'); 29 | * 30 | */ 31 | class KeyboardControls { 32 | 33 | private clientEngine: ClientEngine; 34 | private gameEngine: GameEngine; 35 | private boundKeys: { [key: string]: KeyAction }; 36 | private keyStates: { [key: string]: KeyState }; 37 | private lastKeyPressed: string | null; 38 | 39 | constructor(clientEngine: ClientEngine) { 40 | 41 | this.clientEngine = clientEngine; 42 | this.gameEngine = clientEngine.gameEngine; 43 | 44 | this.setupListeners(); 45 | 46 | // keep a reference for key press state 47 | this.keyStates = {}; 48 | 49 | // a list of bound keys and their corresponding actions 50 | this.boundKeys = {}; 51 | 52 | this.gameEngine.on('client__preStep', () => { 53 | for (let keyName of Object.keys(this.boundKeys)) { 54 | if (this.keyStates[keyName] && this.keyStates[keyName].isDown) { 55 | 56 | // handle repeat press 57 | if (this.boundKeys[keyName].options.repeat || this.keyStates[keyName].count == 0) { 58 | 59 | // callback to get live parameters if function 60 | let parameters = this.boundKeys[keyName].parameters; 61 | if (typeof parameters === "function") { 62 | parameters = parameters(); 63 | } 64 | 65 | // todo movement is probably redundant 66 | let inputOptions = Object.assign({ 67 | movement: true 68 | }, parameters || {}); 69 | this.clientEngine.sendInput(this.boundKeys[keyName].actionName, inputOptions); 70 | this.keyStates[keyName].count++; 71 | } 72 | } 73 | } 74 | }); 75 | } 76 | 77 | setupListeners() { 78 | document.addEventListener('keydown', (e) => { this.onKeyChange(e, true);}); 79 | document.addEventListener('keyup', (e) => { this.onKeyChange(e, false);}); 80 | } 81 | 82 | /** 83 | * Bind a keyboard key to a Lance client event. Each time the key is pressed, 84 | * an event will be transmitted by the client engine, using {@link ClientEngine#sendInput}, 85 | * and the specified event name. 86 | * 87 | * Common key names: up, down, left, right, enter, shift, ctrl, alt, 88 | * escape, space, page up, page down, end, home, 0..9, a..z, A..Z. 89 | * For a full list, please check the source link above. 90 | * 91 | * @param {String} keys - keyboard key (or array of keys) which will cause the event. 92 | * @param {String} actionName - the event name 93 | * @param {Object} options - options object 94 | * @param {Boolean} options.repeat - if set to true, an event continues to be sent on each game step, while the key is pressed 95 | * @param {Object/Function} parameters - parameters (or function to get parameters) to be sent to 96 | * the server with sendInput as the inputOptions 97 | */ 98 | bindKey(keys: string | string[], actionName: string, options?: KeyOptions, parameters?: any) { 99 | if (!Array.isArray(keys)) keys = [keys]; 100 | 101 | let keyOptions = Object.assign({ 102 | repeat: false 103 | }, options); 104 | 105 | keys.forEach(keyName => { 106 | this.boundKeys[keyName] = { actionName, options: keyOptions, parameters: parameters }; 107 | }); 108 | } 109 | 110 | // todo implement unbindKey 111 | onKeyChange(e: KeyboardEvent, isDown: boolean) { 112 | if (e.code && this.boundKeys[e.code]) { 113 | if (this.keyStates[e.code] == null) { 114 | this.keyStates[e.code] = { isDown: false, count: 0 }; 115 | } 116 | this.keyStates[e.code].isDown = isDown; 117 | 118 | // key up, reset press count 119 | if (!isDown) this.keyStates[e.code].count = 0; 120 | 121 | // keep reference to the last key pressed to avoid duplicates 122 | this.lastKeyPressed = isDown ? e.code : null; 123 | 124 | e.preventDefault(); 125 | } 126 | } 127 | } 128 | 129 | export { KeyboardControls }; 130 | -------------------------------------------------------------------------------- /src/game/Timer.ts: -------------------------------------------------------------------------------- 1 | 2 | type TimerCallback = (args: any) => void; 3 | 4 | // TODO: needs documentation 5 | // I think the API could be simpler 6 | // - Timer.run(waitSteps, cb) 7 | // - Timer.repeat(waitSteps, count, cb) // count=null=>forever 8 | // - Timer.cancel(cb) 9 | class Timer { 10 | public currentTime: number; 11 | public idCounter: number; 12 | private isActive: boolean; 13 | private events: { [key: number]: TimerEvent } 14 | 15 | constructor() { 16 | this.currentTime = 0; 17 | this.isActive = false; 18 | this.idCounter = 0; 19 | this.events = {}; 20 | } 21 | 22 | play() { 23 | this.isActive = true; 24 | } 25 | 26 | tick() { 27 | let event; 28 | let eventId; 29 | 30 | if (this.isActive) { 31 | this.currentTime++; 32 | 33 | for (eventId in this.events) { 34 | event = this.events[eventId]; 35 | if (event) { 36 | 37 | if (event.type == 'repeat') { 38 | if ((this.currentTime - event.startOffset) % event.time == 0) { 39 | event.callback.apply(event.thisContext, event.args); 40 | } 41 | } 42 | if (event.type == 'single') { 43 | if ((this.currentTime - event.startOffset) % event.time == 0) { 44 | event.callback.apply(event.thisContext, event.args); 45 | event.destroy(); 46 | } 47 | } 48 | } 49 | } 50 | } 51 | } 52 | 53 | destroyEvent(eventId: number) { 54 | delete this.events[eventId]; 55 | } 56 | 57 | loop(time: number, callback: TimerCallback) { 58 | let timerEvent = new TimerEvent(this, 59 | 'repeat', 60 | time, 61 | callback 62 | ); 63 | 64 | this.events[timerEvent.id] = timerEvent; 65 | 66 | return timerEvent; 67 | } 68 | 69 | add(time: number, callback: TimerCallback, thisContext: any, args: any): TimerEvent { 70 | let timerEvent = new TimerEvent(this, 71 | 'single', 72 | time, 73 | callback, 74 | thisContext, 75 | args 76 | ); 77 | 78 | this.events[timerEvent.id] = timerEvent; 79 | return timerEvent; 80 | } 81 | 82 | // todo implement timer delete all events 83 | 84 | destroy(id: number) { 85 | delete this.events[id]; 86 | } 87 | } 88 | 89 | // timer event 90 | class TimerEvent { 91 | public id: number; 92 | private timer: Timer; 93 | private type: TimerEventType; 94 | private time: number; 95 | private callback: (args: any) => void; 96 | private startOffset: number; 97 | private thisContext: any; 98 | private args: any; 99 | public destroy: () => void; 100 | 101 | constructor(timer: Timer, type: TimerEventType, time: number, callback, thisContext: any = null, args: any = null) { 102 | this.id = ++timer.idCounter; 103 | this.timer = timer; 104 | this.type = type; 105 | this.time = time; 106 | this.callback = callback; 107 | this.startOffset = timer.currentTime; 108 | this.thisContext = thisContext; 109 | this.args = args; 110 | 111 | this.destroy = function() { 112 | this.timer.destroy(this.id); 113 | }; 114 | } 115 | } 116 | 117 | 118 | type TimerEventType = 'repeat' | 'single'; 119 | 120 | export { Timer, TimerCallback } 121 | -------------------------------------------------------------------------------- /src/lib/MathUtils.ts: -------------------------------------------------------------------------------- 1 | class MathUtils { 2 | 3 | // interpolate from start to end, advancing "percent" of the way 4 | static interpolate(start: number, end: number, percent: number): number { 5 | return (end - start) * percent + start; 6 | } 7 | 8 | // interpolate from start to end, advancing "percent" of the way 9 | // 10 | // returns just the delta. i.e. the value that must be added to the start value 11 | static interpolateDelta(start: number, end: number, percent: number): number { 12 | return (end - start) * percent; 13 | } 14 | 15 | // interpolate from start to end, advancing "percent" of the way 16 | // and noting that the dimension wraps around {x >= wrapMin, x < wrapMax} 17 | // 18 | // returns just the delta. i.e. the value that must be added to the start value 19 | static interpolateDeltaWithWrapping(start: number, end: number, percent: number, wrapMin: number, wrapMax: number): number { 20 | let wrapTest = wrapMax - wrapMin; 21 | if (start - end > wrapTest / 2) end += wrapTest; 22 | else if (end - start > wrapTest / 2) start += wrapTest; 23 | if (Math.abs(start - end) > wrapTest / 3) { 24 | console.log('wrap interpolation is close to limit. Not sure which edge to wrap to.'); 25 | } 26 | return (end - start) * percent; 27 | } 28 | 29 | static interpolateWithWrapping(start: number, end: number, percent: number, wrapMin: number, wrapMax: number): number { 30 | let interpolatedVal = start + this.interpolateDeltaWithWrapping(start, end, percent, wrapMin, wrapMax); 31 | let wrapLength = wrapMax - wrapMin; 32 | if (interpolatedVal >= wrapLength) interpolatedVal -= wrapLength; 33 | if (interpolatedVal < 0) interpolatedVal += wrapLength; 34 | return interpolatedVal; 35 | } 36 | } 37 | 38 | export { MathUtils } -------------------------------------------------------------------------------- /src/lib/Scheduler.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'event-emitter'; 2 | 3 | const SIXTY_PER_SEC = 1000 / 60; 4 | const LOOP_SLOW_THRESH = 0.3; 5 | const LOOP_SLOW_COUNT = 10; 6 | 7 | type SchedulerOptions = { 8 | tick: () => void, 9 | period: number, 10 | delay: number 11 | } 12 | 13 | /** 14 | * Scheduler class 15 | * 16 | */ 17 | class Scheduler { 18 | 19 | private options: SchedulerOptions; 20 | private nextExecTime: number; 21 | private requestedDelay: number; 22 | private delayCounter: number; 23 | public emit: (event: string, arg?: any) => void; 24 | public on: (event: string, handler: any) => void; 25 | public once: (event: string, handler: any) => void; 26 | 27 | /** 28 | * schedule a function to be called 29 | * 30 | * @param {Object} options the options 31 | * @param {Function} options.tick the function to be called 32 | * @param {Number} options.period number of milliseconds between each invocation, not including the function's execution time 33 | * @param {Number} options.delay number of milliseconds to add when delaying or hurrying the execution 34 | */ 35 | constructor(options: SchedulerOptions) { 36 | this.options = Object.assign({ 37 | tick: null, 38 | period: SIXTY_PER_SEC, 39 | delay: SIXTY_PER_SEC / 3 40 | }, options); 41 | this.nextExecTime = 0; 42 | this.requestedDelay = 0; 43 | this.delayCounter = 0; 44 | 45 | // mixin for EventEmitter 46 | let eventEmitter = EventEmitter(); 47 | this.on = eventEmitter.on; 48 | this.once = eventEmitter.once; 49 | this.emit = eventEmitter.emit; 50 | } 51 | 52 | // in same cases, setTimeout is ignored by the browser, 53 | // this is known to happen during the first 100ms of a touch event 54 | // on android chrome. Double-check the game loop using requestAnimationFrame 55 | nextTickChecker() { 56 | let currentTime = (new Date()).getTime(); 57 | if (currentTime > this.nextExecTime) { 58 | this.delayCounter++; 59 | this.callTick(); 60 | this.nextExecTime = currentTime + this.options.period; 61 | } 62 | window.requestAnimationFrame(this.nextTickChecker.bind(this)); 63 | } 64 | 65 | nextTick() { 66 | let stepStartTime = (new Date()).getTime(); 67 | if (stepStartTime > this.nextExecTime + this.options.period * LOOP_SLOW_THRESH) { 68 | this.delayCounter++; 69 | } else 70 | this.delayCounter = 0; 71 | 72 | this.callTick(); 73 | this.nextExecTime = stepStartTime + this.options.period + this.requestedDelay; 74 | this.requestedDelay = 0; 75 | setTimeout(this.nextTick.bind(this), this.nextExecTime - (new Date()).getTime()); 76 | } 77 | 78 | callTick() { 79 | if (this.delayCounter >= LOOP_SLOW_COUNT) { 80 | this.emit('loopRunningSlow'); 81 | this.delayCounter = 0; 82 | } 83 | this.options.tick(); 84 | } 85 | 86 | /** 87 | * start the schedule 88 | * @return {Scheduler} returns this scheduler instance 89 | */ 90 | start() { 91 | setTimeout(this.nextTick.bind(this)); 92 | if (typeof window === 'object' && typeof window.requestAnimationFrame === 'function') 93 | window.requestAnimationFrame(this.nextTickChecker.bind(this)); 94 | return this; 95 | } 96 | 97 | /** 98 | * delay next execution 99 | */ 100 | delayTick() { 101 | this.requestedDelay += this.options.delay; 102 | } 103 | 104 | /** 105 | * hurry the next execution 106 | */ 107 | hurryTick() { 108 | this.requestedDelay -= this.options.delay; 109 | } 110 | } 111 | 112 | export { Scheduler, SchedulerOptions } -------------------------------------------------------------------------------- /src/lib/Trace.ts: -------------------------------------------------------------------------------- 1 | type TraceOptions = { 2 | traceLevel: number 3 | } 4 | 5 | type StepDesc = 'initializing' | number; 6 | 7 | type TraceEntry = { 8 | data: string, 9 | level: number, 10 | step: StepDesc, 11 | time: Date 12 | } 13 | 14 | type TraceDataCollector = () => string; 15 | 16 | /** 17 | * Tracing Services. 18 | * Use the trace functions to trace game state. Turn on tracing by 19 | * specifying the minimum trace level which should be recorded. For 20 | * example, setting traceLevel to Trace.TRACE_INFO will cause info, 21 | * warn, and error traces to be recorded. 22 | */ 23 | class Trace { 24 | private options: TraceOptions; 25 | private traceBuffer: TraceEntry[]; 26 | private step: StepDesc; 27 | public error: (tdc: TraceDataCollector) => void; 28 | public warn: (tdc: TraceDataCollector) => void; 29 | public info: (tdc: TraceDataCollector) => void; 30 | public debug: (tdc: TraceDataCollector) => void; 31 | 32 | constructor(options: TraceOptions) { 33 | 34 | this.options = Object.assign({ 35 | traceLevel: Trace.TRACE_DEBUG 36 | }, options); 37 | 38 | this.traceBuffer = []; 39 | this.step = 'initializing'; 40 | 41 | // syntactic sugar functions 42 | this.error = this.trace.bind(this, Trace.TRACE_ERROR); 43 | this.warn = this.trace.bind(this, Trace.TRACE_WARN); 44 | this.info = this.trace.bind(this, Trace.TRACE_INFO); 45 | this.debug = this.trace.bind(this, Trace.TRACE_DEBUG); 46 | this.trace = this.trace.bind(this, Trace.TRACE_ALL); 47 | } 48 | 49 | /** 50 | * Include all trace levels. 51 | * @memberof Trace 52 | * @member {Number} TRACE_ALL 53 | */ 54 | static get TRACE_ALL() { return 0; } 55 | 56 | /** 57 | * Include debug traces and higher. 58 | * @memberof Trace 59 | * @member {Number} TRACE_DEBUG 60 | */ 61 | static get TRACE_DEBUG() { return 1; } 62 | 63 | /** 64 | * Include info traces and higher. 65 | * @memberof Trace 66 | * @member {Number} TRACE_INFO 67 | */ 68 | static get TRACE_INFO() { return 2; } 69 | 70 | /** 71 | * Include warn traces and higher. 72 | * @memberof Trace 73 | * @member {Number} TRACE_WARN 74 | */ 75 | static get TRACE_WARN() { return 3; } 76 | 77 | /** 78 | * Include error traces and higher. 79 | * @memberof Trace 80 | * @member {Number} TRACE_ERROR 81 | */ 82 | static get TRACE_ERROR() { return 4; } 83 | 84 | /** 85 | * Disable all tracing. 86 | * @memberof Trace 87 | * @member {Number} TRACE_NONE 88 | */ 89 | static get TRACE_NONE() { return 1000; } 90 | 91 | trace(level: number, dataCB: TraceDataCollector) { 92 | 93 | if (level < this.options.traceLevel) 94 | return; 95 | 96 | this.traceBuffer.push({ data: dataCB(), level, step: this.step, time: new Date() }); 97 | } 98 | 99 | rotate() { 100 | let buffer = this.traceBuffer; 101 | this.traceBuffer = []; 102 | return buffer; 103 | } 104 | 105 | get length(): number { 106 | return this.traceBuffer.length; 107 | } 108 | 109 | setStep(s: StepDesc) { 110 | this.step = s; 111 | } 112 | } 113 | 114 | export default Trace; 115 | -------------------------------------------------------------------------------- /src/lib/Utils.ts: -------------------------------------------------------------------------------- 1 | export default class Utils { 2 | 3 | static hashStr(str: string, bits = 8): number { 4 | let hash = 5381; 5 | let i = str.length; 6 | 7 | while (i) { 8 | hash = (hash * 33) ^ str.charCodeAt(--i); 9 | } 10 | hash = hash >>> 0; 11 | hash = hash % (Math.pow(2, bits) - 1); 12 | 13 | // JavaScript does bitwise operations (like XOR, above) on 32-bit signed 14 | // integers. Since we want the results to be always positive, convert the 15 | // signed int to an unsigned by doing an unsigned bitshift. */ 16 | return hash; 17 | } 18 | 19 | static arrayBuffersEqual(buf1: ArrayBuffer, buf2: ArrayBuffer): boolean { 20 | if (buf1.byteLength !== buf2.byteLength) return false; 21 | let dv1 = new Int8Array(buf1); 22 | let dv2 = new Int8Array(buf2); 23 | for (let i = 0; i !== buf1.byteLength; i++) { 24 | if (dv1[i] !== dv2[i]) return false; 25 | } 26 | return true; 27 | } 28 | 29 | static httpGetPromise(url: string): Promise { 30 | return new Promise((resolve, reject) => { 31 | let req = new XMLHttpRequest(); 32 | req.open('GET', url, true); 33 | req.onload = () => { 34 | if (req.status >= 200 && req.status < 400) resolve(JSON.parse(req.responseText)); 35 | else reject(); 36 | }; 37 | req.onerror = () => {}; 38 | req.send(); 39 | }); 40 | } 41 | } -------------------------------------------------------------------------------- /src/lib/lib.ts: -------------------------------------------------------------------------------- 1 | import Trace from './Trace.js'; 2 | 3 | export default { 4 | Trace 5 | }; 6 | -------------------------------------------------------------------------------- /src/network/NetworkMonitor.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'event-emitter'; 2 | import http from 'http'; 3 | import { ClientEngine } from '../ClientEngine.js'; 4 | import { Socket } from 'socket.io-client'; 5 | 6 | /** 7 | * Measures network performance between the client and the server 8 | * Represents both the client and server portions of NetworkMonitor 9 | */ 10 | export default class NetworkMonitor { 11 | 12 | public emit: (event: string, arg?: any) => void; 13 | public on: (event: string, handler: any) => void; 14 | public once: (event: string, handler: any) => void; 15 | private queryIdCounter: number; 16 | private RTTQueries: { [key: number]: number}; 17 | private movingRTTAverage: number; 18 | private movingRTTAverageFrame: number[]; 19 | private movingFPSAverageSize: number; 20 | private clientEngine: ClientEngine; 21 | 22 | constructor() { 23 | 24 | // mixin for EventEmitter 25 | let eventEmitter = EventEmitter(); 26 | this.on = eventEmitter.on; 27 | this.once = eventEmitter.once; 28 | this.emit = eventEmitter.emit; 29 | } 30 | 31 | // client 32 | registerClient(clientEngine: ClientEngine) { 33 | this.queryIdCounter = 0; 34 | this.RTTQueries = {}; 35 | 36 | this.movingRTTAverage = 0; 37 | this.movingRTTAverageFrame = []; 38 | this.movingFPSAverageSize = clientEngine.options.healthCheckRTTSample; 39 | this.clientEngine = clientEngine; 40 | clientEngine.socket.on('RTTResponse', this.onReceivedRTTQuery.bind(this)); 41 | setInterval(this.sendRTTQuery.bind(this), clientEngine.options.healthCheckInterval); 42 | } 43 | 44 | sendRTTQuery() { 45 | // todo implement cleanup of older timestamp 46 | this.RTTQueries[this.queryIdCounter] = new Date().getTime(); 47 | this.clientEngine.socket.emit('RTTQuery', this.queryIdCounter); 48 | this.queryIdCounter++; 49 | } 50 | 51 | onReceivedRTTQuery(queryId: number) { 52 | let RTT = (new Date().getTime()) - this.RTTQueries[queryId]; 53 | 54 | this.movingRTTAverageFrame.push(RTT); 55 | if (this.movingRTTAverageFrame.length > this.movingFPSAverageSize) { 56 | this.movingRTTAverageFrame.shift(); 57 | } 58 | this.movingRTTAverage = this.movingRTTAverageFrame.reduce((a, b) => a + b) / this.movingRTTAverageFrame.length; 59 | this.emit('RTTUpdate', { 60 | RTT: RTT, 61 | RTTAverage: this.movingRTTAverage 62 | }); 63 | } 64 | 65 | // server 66 | registerPlayerOnServer(socket: Socket) { 67 | socket.on('RTTQuery', this.respondToRTTQuery.bind(this, socket)); 68 | } 69 | 70 | respondToRTTQuery(socket: Socket, queryId: number) { 71 | socket.emit('RTTResponse', queryId); 72 | } 73 | 74 | } 75 | -------------------------------------------------------------------------------- /src/network/NetworkTransmitter.ts: -------------------------------------------------------------------------------- 1 | import BaseTypes from '../serialize/BaseTypes.js'; 2 | import NetworkedEventCollection from './NetworkedEventCollection.js'; 3 | import Serializer from '../serialize/Serializer.js'; 4 | import Serializable from '../serialize/Serializable.js'; 5 | 6 | 7 | class ObjectUpdate extends Serializable { 8 | stepCount: number; 9 | objectInstance: Serializable; 10 | 11 | netScheme() { 12 | return { 13 | stepCount: { type: BaseTypes.Int32 }, 14 | objectInstance: { type: BaseTypes.ClassInstance } 15 | }; 16 | } 17 | constructor(stepCount: number, objectInstance: Serializable) { 18 | super(); 19 | this.stepCount = stepCount; 20 | this.objectInstance = objectInstance; 21 | } 22 | } 23 | 24 | class ObjectCreate extends Serializable { 25 | stepCount: number; 26 | objectInstance: Serializable; 27 | 28 | netScheme() { 29 | return { 30 | stepCount: { type: BaseTypes.Int32 }, 31 | objectInstance: { type: BaseTypes.ClassInstance } 32 | } 33 | } 34 | constructor(stepCount: number, objectInstance: Serializable) { 35 | super(); 36 | this.stepCount = stepCount; 37 | this.objectInstance = objectInstance; 38 | } 39 | } 40 | 41 | class ObjectDestroy extends Serializable { 42 | stepCount: number; 43 | objectInstance: Serializable; 44 | 45 | netScheme() { 46 | return { 47 | stepCount: { type: BaseTypes.Int32 }, 48 | objectInstance: { type: BaseTypes.ClassInstance } 49 | } 50 | } 51 | constructor(stepCount: number, objectInstance: Serializable) { 52 | super(); 53 | this.stepCount = stepCount; 54 | this.objectInstance = objectInstance; 55 | } 56 | } 57 | 58 | class SyncHeader extends Serializable { 59 | stepCount: number; 60 | fullUpdate: number; 61 | netScheme() { 62 | return { 63 | stepCount: { type: BaseTypes.Int32 }, 64 | fullUpdate: { type: BaseTypes.Int8 } 65 | } 66 | } 67 | constructor(stepCount: number, fullUpdate: number) { 68 | super(); 69 | this.stepCount = stepCount; 70 | this.fullUpdate = fullUpdate; 71 | } 72 | } 73 | 74 | export default class NetworkTransmitter { 75 | 76 | private serializer: Serializer; 77 | private networkedEventCollection: NetworkedEventCollection; 78 | 79 | 80 | constructor(serializer: Serializer) { 81 | this.serializer = serializer; 82 | this.serializer.registerClass(NetworkedEventCollection); 83 | this.serializer.registerClass(ObjectUpdate); 84 | this.serializer.registerClass(ObjectCreate); 85 | this.serializer.registerClass(ObjectDestroy); 86 | this.serializer.registerClass(SyncHeader); 87 | this.networkedEventCollection = new NetworkedEventCollection([]); 88 | } 89 | 90 | sendUpdate(stepCount: number, obj: Serializable) { 91 | this.networkedEventCollection.events.push(new ObjectUpdate(stepCount, obj)); 92 | } 93 | 94 | sendCreate(stepCount: number, obj: Serializable) { 95 | this.networkedEventCollection.events.push(new ObjectCreate(stepCount, obj)); 96 | } 97 | 98 | sendDestroy(stepCount: number, obj: Serializable) { 99 | this.networkedEventCollection.events.push(new ObjectDestroy(stepCount, obj)); 100 | } 101 | 102 | syncHeader(stepCount: number, fullUpdate: number) { 103 | this.networkedEventCollection.events.push(new SyncHeader(stepCount, fullUpdate)); 104 | } 105 | 106 | 107 | serializePayload() { 108 | if (this.networkedEventCollection.events.length === 0) 109 | return null; 110 | 111 | let dataBuffer = this.networkedEventCollection.serialize(this.serializer, { dry: false, dataBuffer: null, bufferOffset: 0 }); 112 | 113 | return dataBuffer; 114 | } 115 | 116 | deserializePayload(payload) { 117 | return this.serializer.deserialize(payload.dataBuffer, 0).obj; 118 | } 119 | 120 | clearPayload() { 121 | this.networkedEventCollection.events = []; 122 | } 123 | 124 | // TODO: there must be a better way than this 125 | static getNetworkEvent(event: Serializable): string { 126 | if (event instanceof ObjectUpdate) return 'objectUpdate'; 127 | else if (event instanceof ObjectCreate) return 'objectCreate'; 128 | else if (event instanceof ObjectDestroy) return 'objectDestroy'; 129 | else if (event instanceof SyncHeader) return 'syncHeader'; 130 | return 'unknown'; // raise an error here 131 | } 132 | 133 | } 134 | -------------------------------------------------------------------------------- /src/network/NetworkedEventCollection.ts: -------------------------------------------------------------------------------- 1 | import BaseTypes from '../serialize/BaseTypes.js'; 2 | import Serializable from '../serialize/Serializable.js'; 3 | 4 | /** 5 | * Defines a collection of NetworkEvents to be transmitted over the wire 6 | */ 7 | export default class NetworkedEventCollection extends Serializable { 8 | public events: Serializable[]; 9 | 10 | netScheme() { 11 | return { 12 | events: { 13 | type: BaseTypes.List, 14 | itemType: BaseTypes.ClassInstance 15 | }, 16 | }; 17 | } 18 | 19 | constructor(events: Serializable[]) { 20 | super(); 21 | this.events = events; 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/package/allExports.ts: -------------------------------------------------------------------------------- 1 | import { GameEngine, GameEngineOptions, InputDesc, PreStepDesc } from '../GameEngine.js'; 2 | import { GameWorld } from '../GameWorld.js'; 3 | import { P2PhysicsEngine } from '../physics/P2PhysicsEngine.js'; 4 | import { SimplePhysicsEngine, SimplePhysicsEngineOptions } from '../physics/SimplePhysicsEngine.js'; 5 | import { CannonPhysicsEngine } from '../physics/CannonPhysicsEngine.js'; 6 | import BaseTypes from '../serialize/BaseTypes.js'; 7 | import { TwoVector } from '../serialize/TwoVector.js'; 8 | import { ThreeVector } from '../serialize/ThreeVector.js'; 9 | import Quaternion from '../serialize/Quaternion.js'; 10 | import { GameObject } from '../serialize/GameObject.js'; 11 | import DynamicObject from '../serialize/DynamicObject.js'; 12 | import { PhysicalObject2D } from '../serialize/PhysicalObject2D.js'; 13 | import { PhysicalObject3D } from '../serialize/PhysicalObject3D.js'; 14 | import { ServerEngine, ServerEngineOptions } from '../ServerEngine.js'; 15 | import { ClientEngineOptions, ClientEngine } from '../ClientEngine.js'; 16 | import { KeyboardControls } from '../controls/KeyboardControls.js'; 17 | import Renderer from '../render/Renderer.js'; 18 | import AFrameRenderer from '../render/AFrameRenderer.js'; 19 | import { SyncStrategy, SyncStrategyOptions } from '../syncStrategies/SyncStrategy.js'; 20 | import { ExtrapolateStrategy, ExtrapolateSyncStrategyOptions } from '../syncStrategies/ExtrapolateStrategy.js'; 21 | import Lib from '../lib/lib.js'; 22 | import Serializer from '../serialize/Serializer.js'; 23 | import { BruteForceCollisionDetectionOptions } from '../physics/SimplePhysics/BruteForceCollisionDetection.js'; 24 | import { HSHGCollisionDetectionOptions } from '../physics/SimplePhysics/HSHGCollisionDetection.js'; 25 | import { FrameSyncStrategy } from '../syncStrategies/FrameSyncStrategy.js'; 26 | 27 | export { 28 | GameEngine, GameEngineOptions, 29 | GameWorld, 30 | InputDesc, 31 | PreStepDesc, 32 | Serializer, 33 | P2PhysicsEngine, 34 | BruteForceCollisionDetectionOptions, HSHGCollisionDetectionOptions, 35 | SimplePhysicsEngine, SimplePhysicsEngineOptions, 36 | CannonPhysicsEngine, 37 | BaseTypes, 38 | TwoVector, 39 | ThreeVector, 40 | Quaternion, 41 | GameObject, 42 | DynamicObject, 43 | PhysicalObject2D, 44 | PhysicalObject3D, 45 | ServerEngine, ServerEngineOptions, 46 | ClientEngine, ClientEngineOptions, 47 | KeyboardControls, 48 | Renderer, 49 | AFrameRenderer, 50 | SyncStrategy, SyncStrategyOptions, 51 | ExtrapolateStrategy, ExtrapolateSyncStrategyOptions, 52 | FrameSyncStrategy, 53 | Lib, 54 | } 55 | -------------------------------------------------------------------------------- /src/package/clientExports.ts: -------------------------------------------------------------------------------- 1 | import { GameEngine, InputDesc, PreStepDesc } from "../GameEngine.js"; 2 | import { GameWorld } from "../GameWorld.js"; 3 | import { CannonPhysicsEngine } from "../physics/CannonPhysicsEngine.js"; 4 | import { P2PhysicsEngine } from "../physics/P2PhysicsEngine.js"; 5 | import { BruteForceCollisionDetectionOptions } from "../physics/SimplePhysics/BruteForceCollisionDetection.js"; 6 | import { HSHGCollisionDetectionOptions } from "../physics/SimplePhysics/HSHGCollisionDetection.js"; 7 | import { SimplePhysicsEngine, SimplePhysicsEngineOptions } from "../physics/SimplePhysicsEngine.js"; 8 | import BaseTypes from "../serialize/BaseTypes.js"; 9 | import DynamicObject from "../serialize/DynamicObject.js"; 10 | import { GameObject } from "../serialize/GameObject.js"; 11 | import { PhysicalObject2D } from "../serialize/PhysicalObject2D.js"; 12 | import { PhysicalObject3D } from "../serialize/PhysicalObject3D.js"; 13 | import Quaternion from "../serialize/Quaternion.js"; 14 | import Serializer from "../serialize/Serializer.js"; 15 | import { ThreeVector } from "../serialize/ThreeVector.js"; 16 | import { TwoVector } from "../serialize/TwoVector.js"; 17 | import Lib from '../lib/lib.js'; 18 | import { ClientEngine, ClientEngineOptions } from "../ClientEngine.js"; 19 | import { KeyboardControls } from "../controls/KeyboardControls.js"; 20 | import Renderer from "../render/Renderer.js"; 21 | import AFrameRenderer from "../render/AFrameRenderer.js"; 22 | import { SyncStrategy, SyncStrategyOptions } from "../syncStrategies/SyncStrategy.js"; 23 | import { ExtrapolateStrategy, ExtrapolateSyncStrategyOptions } from "../syncStrategies/ExtrapolateStrategy.js"; 24 | import { FrameSyncStrategy } from "../syncStrategies/FrameSyncStrategy.js"; 25 | 26 | 27 | export { 28 | GameEngine, 29 | GameWorld, 30 | InputDesc, 31 | PreStepDesc, 32 | Serializer, 33 | P2PhysicsEngine, 34 | BruteForceCollisionDetectionOptions, 35 | HSHGCollisionDetectionOptions, 36 | SimplePhysicsEngine, SimplePhysicsEngineOptions, 37 | CannonPhysicsEngine, 38 | BaseTypes, 39 | TwoVector, 40 | ThreeVector, 41 | Quaternion, 42 | GameObject, 43 | DynamicObject, 44 | PhysicalObject2D, 45 | PhysicalObject3D, 46 | Lib, 47 | ClientEngineOptions, 48 | ClientEngine, 49 | KeyboardControls, 50 | Renderer, 51 | AFrameRenderer, 52 | SyncStrategy, SyncStrategyOptions, 53 | ExtrapolateStrategy, ExtrapolateSyncStrategyOptions, 54 | FrameSyncStrategy 55 | }; 56 | -------------------------------------------------------------------------------- /src/package/serverExports.ts: -------------------------------------------------------------------------------- 1 | import { GameEngine, GameEngineOptions, InputDesc, PreStepDesc } from '../GameEngine.js'; 2 | import { GameWorld } from '../GameWorld.js'; 3 | import { P2PhysicsEngine } from '../physics/P2PhysicsEngine.js'; 4 | import { SimplePhysicsEngine, SimplePhysicsEngineOptions } from '../physics/SimplePhysicsEngine.js'; 5 | import { CannonPhysicsEngine } from '../physics/CannonPhysicsEngine.js'; 6 | import BaseTypes from '../serialize/BaseTypes.js'; 7 | import { TwoVector } from '../serialize/TwoVector.js'; 8 | import { ThreeVector } from '../serialize/ThreeVector.js'; 9 | import Quaternion from '../serialize/Quaternion.js'; 10 | import { GameObject } from '../serialize/GameObject.js'; 11 | import DynamicObject from '../serialize/DynamicObject.js'; 12 | import { PhysicalObject2D } from '../serialize/PhysicalObject2D.js'; 13 | import { PhysicalObject3D } from '../serialize/PhysicalObject3D.js'; 14 | import { ServerEngine, ServerEngineOptions } from '../ServerEngine.js'; 15 | import Renderer from '../render/Renderer.js'; 16 | import Lib from '../lib/lib.js'; 17 | import Serializer from '../serialize/Serializer.js'; 18 | // TODO: remove Renderer from server exports. 19 | // some games (Brawler) have a renderer which needs to be notified on object create, 20 | // and instead of just listening to the object-create event, they are invoked by the 21 | // object itself on creation 22 | 23 | export { 24 | GameEngine, GameEngineOptions, 25 | GameWorld, 26 | Serializer, 27 | Renderer, 28 | InputDesc, 29 | PreStepDesc, 30 | P2PhysicsEngine, 31 | SimplePhysicsEngine, SimplePhysicsEngineOptions, 32 | CannonPhysicsEngine, 33 | BaseTypes, 34 | TwoVector, 35 | ThreeVector, 36 | Quaternion, 37 | GameObject, 38 | DynamicObject, 39 | PhysicalObject2D, 40 | PhysicalObject3D, 41 | ServerEngine, ServerEngineOptions, 42 | Lib, 43 | } 44 | -------------------------------------------------------------------------------- /src/physics/CannonPhysicsEngine.ts: -------------------------------------------------------------------------------- 1 | import { GameObject } from '../serialize/GameObject.js'; 2 | import { PhysicsEngine, PhysicsEngineOptions } from './PhysicsEngine.js'; 3 | import * as Cannon from 'cannon'; 4 | 5 | interface CannonPhysicsEngineOptions extends PhysicsEngineOptions { 6 | dt: number 7 | } 8 | 9 | /** 10 | * CannonPhysicsEngine is a three-dimensional lightweight physics engine 11 | */ 12 | class CannonPhysicsEngine extends PhysicsEngine { 13 | 14 | private cannonPhysicsEngineOptions: CannonPhysicsEngineOptions; 15 | 16 | constructor(options: CannonPhysicsEngineOptions) { 17 | super(options); 18 | 19 | this.cannonPhysicsEngineOptions = options; 20 | this.cannonPhysicsEngineOptions.dt = this.cannonPhysicsEngineOptions.dt || (1 / 60); 21 | let world = this.world = new Cannon.World(); 22 | world.quatNormalizeSkip = 0; 23 | world.quatNormalizeFast = false; 24 | world.gravity.set(0, -10, 0); 25 | world.broadphase = new Cannon.NaiveBroadphase(); 26 | } 27 | 28 | // entry point for a single step of the Simple Physics 29 | step(dt: number, objectFilter: (o: GameObject) => boolean): void { 30 | this.world.step(dt || this.cannonPhysicsEngineOptions.dt); 31 | } 32 | 33 | addSphere(radius: number, mass: number): Cannon.Body { 34 | let shape = new Cannon.Sphere(radius); 35 | let body = new Cannon.Body({ mass, shape }); 36 | body.position.set(0, 0, 0); 37 | this.world.addBody(body); 38 | return body; 39 | } 40 | 41 | addBox(x: number, y: number, z: number, mass: number, friction: number): Cannon.Body { 42 | let shape = new Cannon.Box(new Cannon.Vec3(x, y, z)); 43 | let options: Cannon.IBodyOptions = { mass, shape }; 44 | if (friction !== undefined) 45 | options.material = new Cannon.Material('material'); 46 | let body = new Cannon.Body(options); 47 | body.position.set(0, 0, 0); 48 | this.world.addBody(body); 49 | return body; 50 | } 51 | 52 | addCylinder(radiusTop: number, radiusBottom: number, height: number, numSegments: number, mass: number): Cannon.Body { 53 | let shape = new Cannon.Cylinder(radiusTop, radiusBottom, height, numSegments); 54 | let body = new Cannon.Body({ mass, shape }); 55 | this.world.addBody(body); 56 | return body; 57 | } 58 | 59 | removeObject(obj: Cannon.Body): void { 60 | this.world.remove(obj); 61 | } 62 | } 63 | 64 | export { CannonPhysicsEngine, CannonPhysicsEngineOptions } 65 | -------------------------------------------------------------------------------- /src/physics/P2PhysicsEngine.ts: -------------------------------------------------------------------------------- 1 | import { GameObject } from '../serialize/GameObject.js'; 2 | import { PhysicsEngine, PhysicsEngineOptions } from './PhysicsEngine.js'; 3 | import P2, { BodyOptions, CircleOptions } from 'p2'; 4 | 5 | interface P2PhysicsEngineOptions extends PhysicsEngineOptions { 6 | dt?: number 7 | } 8 | 9 | /** 10 | * P2PhysicsEngine is a three-dimensional lightweight physics engine 11 | */ 12 | class P2PhysicsEngine extends PhysicsEngine { 13 | private p2PhysicsEngineOptions: P2PhysicsEngineOptions; 14 | 15 | constructor(options: P2PhysicsEngineOptions) { 16 | super(options); 17 | this.p2PhysicsEngineOptions = options; 18 | this.p2PhysicsEngineOptions.dt = this.p2PhysicsEngineOptions.dt || (1 / 60); 19 | this.world = new P2.World({ gravity: [0, 0] }); 20 | } 21 | 22 | // entry point for a single step of the P2 Physics 23 | step(dt: number, objectFilter: (o: GameObject) => boolean): void { 24 | this.world.step(dt || this.p2PhysicsEngineOptions.dt); 25 | } 26 | 27 | // add a circle 28 | addCircle(circleOptions: CircleOptions, bodyOptions: BodyOptions): P2.Body { 29 | 30 | // create a body, add shape, add to world 31 | let body = new P2.Body(bodyOptions); 32 | body.addShape(new P2.Circle(circleOptions)); 33 | this.world.addBody(body); 34 | 35 | return body; 36 | } 37 | 38 | addBox(width: number, height: number, mass: number) { 39 | 40 | // create a body, add shape, add to world 41 | let body = new P2.Body({ mass, position: [0, 0] }); 42 | body.addShape(new P2.Box({ width, height })); 43 | this.world.addBody(body); 44 | 45 | return body; 46 | } 47 | 48 | removeObject(obj: P2.Body) { 49 | this.world.removeBody(obj); 50 | } 51 | } 52 | 53 | export { P2PhysicsEngine, P2PhysicsEngineOptions } 54 | -------------------------------------------------------------------------------- /src/physics/PhysicsEngine.ts: -------------------------------------------------------------------------------- 1 | import { GameEngine } from '../GameEngine.js'; 2 | import { GameObject } from "../serialize/GameObject.js"; 3 | 4 | // The base Physics Engine class defines the expected interface 5 | // for all physics engines 6 | 7 | interface PhysicsEngineOptions { 8 | gameEngine: GameEngine; 9 | } 10 | 11 | class PhysicsEngine { 12 | 13 | private options: PhysicsEngineOptions; 14 | protected gameEngine: GameEngine; 15 | public world?: any; 16 | 17 | constructor(options: PhysicsEngineOptions) { 18 | this.options = options; 19 | this.gameEngine = options.gameEngine; 20 | 21 | if (!options.gameEngine) { 22 | console.warn('Physics engine initialized without gameEngine!'); 23 | } 24 | } 25 | 26 | /** 27 | * A single Physics step. 28 | * 29 | * @param {Number} dt - time elapsed since last step 30 | * @param {Function} objectFilter - a test function which filters which objects should move 31 | */ 32 | step(dt: number, objectFilter: (o: GameObject) => boolean) {} 33 | 34 | } 35 | 36 | export { PhysicsEngine, PhysicsEngineOptions } -------------------------------------------------------------------------------- /src/physics/SimplePhysics/BruteForceCollisionDetection.ts: -------------------------------------------------------------------------------- 1 | import { GameEngine } from '../../GameEngine.js'; 2 | import DynamicObject from '../../serialize/DynamicObject.js'; 3 | import { TwoVector } from '../../serialize/TwoVector.js'; 4 | import { CollisionDetection, CollisionDetectionOptions } from './CollisionDetection.js'; 5 | let differenceVector = new TwoVector(0, 0); 6 | 7 | interface BruteForceCollisionDetectionOptions extends CollisionDetectionOptions { 8 | autoResolve: boolean, 9 | collisionDistance: number 10 | } 11 | 12 | // The collision detection of SimplePhysicsEngine is a brute-force approach 13 | class BruteForceCollisionDetection implements CollisionDetection { 14 | 15 | private options: BruteForceCollisionDetectionOptions; 16 | private gameEngine: GameEngine; 17 | private collisionPairs: { [key: string]: boolean } 18 | 19 | constructor(options: BruteForceCollisionDetectionOptions) { 20 | this.options = Object.assign({ 21 | autoResolve: true 22 | }, options); 23 | this.collisionPairs = {}; 24 | } 25 | 26 | // TODO: why do we need init as well as constructor? 27 | // TODO: HSHGCollisionDetectionOptions is uselessly passed twice, both on constructor and on init (below) 28 | init(options: BruteForceCollisionDetectionOptions) { 29 | this.gameEngine = options.gameEngine; 30 | } 31 | 32 | findCollision(o1: DynamicObject, o2: DynamicObject) { 33 | 34 | // static objects don't collide 35 | if (o1.isStatic && o2.isStatic) 36 | return false; 37 | 38 | // allow a collision checker function 39 | if (typeof o1.collidesWith === 'function') { 40 | if (!o1.collidesWith(o2)) 41 | return false; 42 | } 43 | 44 | // radius-based collision 45 | if (this.options.collisionDistance) { 46 | differenceVector.copy(o1.position).subtract(o2.position); 47 | return differenceVector.length() < this.options.collisionDistance; 48 | } 49 | 50 | // check for no-collision first 51 | let o1Box = getBox(o1); 52 | let o2Box = getBox(o2); 53 | if (o1Box.xMin > o2Box.xMax || 54 | o1Box.yMin > o2Box.yMax || 55 | o2Box.xMin > o1Box.xMax || 56 | o2Box.yMin > o1Box.yMax) 57 | return false; 58 | 59 | if (!this.options.autoResolve) 60 | return true; 61 | 62 | // need to auto-resolve 63 | let shiftY1 = o2Box.yMax - o1Box.yMin; 64 | let shiftY2 = o1Box.yMax - o2Box.yMin; 65 | let shiftX1 = o2Box.xMax - o1Box.xMin; 66 | let shiftX2 = o1Box.xMax - o2Box.xMin; 67 | let smallestYShift = Math.min(Math.abs(shiftY1), Math.abs(shiftY2)); 68 | let smallestXShift = Math.min(Math.abs(shiftX1), Math.abs(shiftX2)); 69 | 70 | // choose to apply the smallest shift which solves the collision 71 | if (smallestYShift < smallestXShift) { 72 | if (o1Box.yMin > o2Box.yMin && o1Box.yMin < o2Box.yMax) { 73 | if (o2.isStatic) o1.position.y += shiftY1; 74 | else if (o1.isStatic) o2.position.y -= shiftY1; 75 | else { 76 | o1.position.y += shiftY1 / 2; 77 | o2.position.y -= shiftY1 / 2; 78 | } 79 | } else if (o1Box.yMax > o2Box.yMin && o1Box.yMax < o2Box.yMax) { 80 | if (o2.isStatic) o1.position.y -= shiftY2; 81 | else if (o1.isStatic) o2.position.y += shiftY2; 82 | else { 83 | o1.position.y -= shiftY2 / 2; 84 | o2.position.y += shiftY2 / 2; 85 | } 86 | } 87 | o1.velocity.y = 0; 88 | o2.velocity.y = 0; 89 | } else { 90 | if (o1Box.xMin > o2Box.xMin && o1Box.xMin < o2Box.xMax) { 91 | if (o2.isStatic) o1.position.x += shiftX1; 92 | else if (o1.isStatic) o2.position.x -= shiftX1; 93 | else { 94 | o1.position.x += shiftX1 / 2; 95 | o2.position.x -= shiftX1 / 2; 96 | } 97 | } else if (o1Box.xMax > o2Box.xMin && o1Box.xMax < o2Box.xMax) { 98 | if (o2.isStatic) o1.position.x -= shiftX2; 99 | else if (o1.isStatic) o2.position.x += shiftX2; 100 | else { 101 | o1.position.x -= shiftX2 / 2; 102 | o2.position.x += shiftX2 / 2; 103 | } 104 | } 105 | o1.velocity.x = 0; 106 | o2.velocity.x = 0; 107 | } 108 | 109 | return true; 110 | } 111 | 112 | // check if pair (id1, id2) have collided 113 | checkPair(id1: string, id2: string) { 114 | let objects = this.gameEngine.world.objects; 115 | let o1 = objects[id1]; 116 | let o2 = objects[id2]; 117 | 118 | // make sure that objects actually exist. might have been destroyed 119 | if (!o1 || !o2) return; 120 | let pairId = [id1, id2].join(','); 121 | 122 | if (this.findCollision( o1, o2)) { 123 | if (!(pairId in this.collisionPairs)) { 124 | this.collisionPairs[pairId] = true; 125 | this.gameEngine.emit('collisionStart', { o1, o2 }); 126 | } 127 | } else if (pairId in this.collisionPairs) { 128 | this.gameEngine.emit('collisionStop', { o1, o2 }); 129 | delete this.collisionPairs[pairId]; 130 | } 131 | } 132 | 133 | // detect by checking all pairs 134 | detect() { 135 | let objects = this.gameEngine.world.objects; 136 | let keys = Object.keys(objects); 137 | 138 | // delete non existant object pairs 139 | for (let pairId in this.collisionPairs) 140 | if (this.collisionPairs.hasOwnProperty(pairId)) 141 | if (keys.indexOf(pairId.split(',')[0]) === -1 || keys.indexOf(pairId.split(',')[1]) === -1) 142 | delete this.collisionPairs[pairId]; 143 | 144 | // check all pairs 145 | for (let k1 of keys) 146 | for (let k2 of keys) 147 | if (k2 > k1) this.checkPair(k1, k2); 148 | } 149 | } 150 | 151 | // get bounding box of object o 152 | function getBox(o: DynamicObject) { 153 | return { 154 | xMin: o.position.x, 155 | xMax: o.position.x + o.width, 156 | yMin: o.position.y, 157 | yMax: o.position.y + o.height 158 | }; 159 | } 160 | 161 | export { BruteForceCollisionDetection, BruteForceCollisionDetectionOptions } -------------------------------------------------------------------------------- /src/physics/SimplePhysics/CollisionDetection.ts: -------------------------------------------------------------------------------- 1 | import { GameEngine } from '../../GameEngine.js'; 2 | 3 | interface CollisionDetectionOptions { 4 | gameEngine: GameEngine 5 | } 6 | 7 | interface CollisionDetection { 8 | init(options: CollisionDetectionOptions); 9 | detect(); 10 | } 11 | 12 | export { CollisionDetection, CollisionDetectionOptions } -------------------------------------------------------------------------------- /src/physics/SimplePhysics/HSHGCollisionDetection.ts: -------------------------------------------------------------------------------- 1 | import { GameEngine } from '../../GameEngine.js'; 2 | import { CollisionDetection, CollisionDetectionOptions } from './CollisionDetection.js'; 3 | import { HSHGDetector } from './HSHG.js'; 4 | 5 | interface HSHGCollisionDetectionOptions extends CollisionDetectionOptions { 6 | gameEngine: GameEngine, 7 | COLLISION_DISTANCE?: number 8 | } 9 | 10 | type PairsDict = { 11 | [key: string]: { o1: any, o2: any } 12 | } 13 | 14 | interface CollisionObject { 15 | id: number 16 | } 17 | 18 | // Collision detection based on Hierarchical Spatial Hash Grid 19 | // uses this implementation https://gist.github.com/kirbysayshi/1760774 20 | class HSHGCollisionDetection implements CollisionDetection { 21 | 22 | private options: HSHGCollisionDetectionOptions; 23 | private gameEngine: GameEngine; 24 | private grid: HSHGDetector; 25 | private previousCollisionPairs: PairsDict; 26 | private stepCollidingPairs: PairsDict; 27 | 28 | // TODO: HSHGCollisionDetectionOptions is uselessly passed twice, both on constructor and on init (below) 29 | constructor(options: HSHGCollisionDetectionOptions) { 30 | this.options = Object.assign({ COLLISION_DISTANCE: 28 }, options); 31 | } 32 | 33 | init(options: HSHGCollisionDetectionOptions) { 34 | this.gameEngine = options.gameEngine; 35 | this.grid = new HSHGDetector(); 36 | this.previousCollisionPairs = {}; 37 | this.stepCollidingPairs = {}; 38 | 39 | this.gameEngine.on('objectAdded', obj => { 40 | // add the gameEngine obj the the spatial grid 41 | this.grid.addObject(obj); 42 | }); 43 | 44 | this.gameEngine.on('objectDestroyed', obj => { 45 | // add the gameEngine obj the the spatial grid 46 | this.grid.removeObject(obj); 47 | }); 48 | } 49 | 50 | detect() { 51 | this.grid.update(); 52 | this.stepCollidingPairs = this.grid.queryForCollisionPairs().reduce((accumulator, currentValue, i) => { 53 | let pairId = getArrayPairId(currentValue); 54 | accumulator[pairId] = { o1: currentValue[0], o2: currentValue[1] }; 55 | return accumulator; 56 | }, {}); 57 | 58 | for (let pairId of Object.keys(this.previousCollisionPairs)) { 59 | let pairObj = this.previousCollisionPairs[pairId]; 60 | 61 | // existed in previous pairs, but not during this step: this pair stopped colliding 62 | if (pairId in this.stepCollidingPairs === false) { 63 | this.gameEngine.emit('collisionStop', pairObj); 64 | } 65 | } 66 | 67 | for (let pairId of Object.keys(this.stepCollidingPairs)) { 68 | let pairObj = this.stepCollidingPairs[pairId]; 69 | 70 | // didn't exist in previous pairs, but exists now: this is a new colliding pair 71 | if (pairId in this.previousCollisionPairs === false) { 72 | this.gameEngine.emit('collisionStart', pairObj); 73 | } 74 | } 75 | 76 | this.previousCollisionPairs = this.stepCollidingPairs; 77 | } 78 | 79 | /** 80 | * checks wheter two objects are currently colliding 81 | * @param {Object} o1 first object 82 | * @param {Object} o2 second object 83 | * @return {boolean} are the two objects colliding? 84 | */ 85 | areObjectsColliding(o1, o2) { 86 | return getArrayPairId([o1, o2]) in this.stepCollidingPairs; 87 | } 88 | 89 | } 90 | 91 | function getArrayPairId(arrayPair: CollisionObject[]): string { 92 | // make sure to get the same id regardless of object order 93 | // slice(0) is used as a fast mechanism to create a shallow copy 94 | let sortedArrayPair = arrayPair.slice(0).sort(); 95 | return sortedArrayPair[0].id + '-' + sortedArrayPair[1].id; 96 | } 97 | 98 | export { HSHGCollisionDetection, HSHGCollisionDetectionOptions } 99 | -------------------------------------------------------------------------------- /src/physics/SimplePhysicsEngine.ts: -------------------------------------------------------------------------------- 1 | import { PhysicsEngine, PhysicsEngineOptions } from './PhysicsEngine.js'; 2 | import { TwoVector } from '../serialize/TwoVector.js'; 3 | import { HSHGCollisionDetection, HSHGCollisionDetectionOptions } from './SimplePhysics/HSHGCollisionDetection.js'; 4 | import { BruteForceCollisionDetection, BruteForceCollisionDetectionOptions } from './SimplePhysics/BruteForceCollisionDetection.js'; 5 | import { CollisionDetection } from './SimplePhysics/CollisionDetection.js'; 6 | import { GameEngine } from '../GameEngine.js'; 7 | import { GameObject } from '../serialize/GameObject.js'; 8 | import DynamicObject from '../serialize/DynamicObject.js'; 9 | 10 | let dv = new TwoVector(0, 0); 11 | let dx = new TwoVector(0, 0); 12 | 13 | interface SimplePhysicsEngineOptions extends PhysicsEngineOptions { 14 | collisionsType?: "HSHG" | "bruteForce", 15 | collisions?: HSHGCollisionDetectionOptions | BruteForceCollisionDetectionOptions, 16 | gravity?: TwoVector, 17 | } 18 | /** 19 | * SimplePhysicsEngine is a pseudo-physics engine which works with 20 | * objects of class DynamicObject. 21 | * The Simple Physics Engine is a "fake" physics engine, which is more 22 | * appropriate for arcade games, and it is sometimes referred to as "arcade" 23 | * physics. For example if a character is standing at the edge of a platform, 24 | * with only one foot on the platform, it won't fall over. This is a desired 25 | * game behaviour in platformer games. 26 | */ 27 | class SimplePhysicsEngine extends PhysicsEngine { 28 | 29 | private collisionDetection: CollisionDetection; 30 | private gravity: TwoVector; 31 | 32 | /** 33 | * Creates an instance of the Simple Physics Engine. 34 | * @param {Object} options - physics options 35 | * @param {Object} options.collisions - collision options 36 | * @param {String} options.collisions.type - can be set to "HSHG" or "bruteForce". Default is Brute-Force collision detection. 37 | * @param {Number} options.collisions.collisionDistance - for brute force, this can be set for a simple distance-based (radius) collision detection. 38 | * @param {Boolean} options.collisions.autoResolve - for brute force collision, colliding objects should be moved apart 39 | * @param {TwoVector} options.gravity - TwoVector instance which describes gravity, which will be added to the velocity of all objects at every step. For example TwoVector(0, -0.01) 40 | */ 41 | constructor(options: SimplePhysicsEngineOptions) { 42 | super(options); 43 | 44 | 45 | // todo does this mean both modules always get loaded? 46 | if (options.collisions && options.collisionsType === 'HSHG') { 47 | this.collisionDetection = new HSHGCollisionDetection(options.collisions); 48 | } else { 49 | this.collisionDetection = new BruteForceCollisionDetection( options.collisions); 50 | } 51 | 52 | /** 53 | * The actor's name. 54 | * @memberof SimplePhysicsEngine 55 | * @member {TwoVector} gravity affecting all objects 56 | */ 57 | this.gravity = new TwoVector(0, 0); 58 | 59 | if (options.gravity) 60 | this.gravity.copy(options.gravity); 61 | 62 | let collisionOptions = Object.assign({ gameEngine: this.gameEngine }, options.collisions); 63 | this.collisionDetection.init(collisionOptions); 64 | } 65 | 66 | // a single object advances, based on: 67 | // isRotatingRight, isRotatingLeft, isAccelerating, current velocity 68 | // wrap-around the world if necessary 69 | objectStep(o: DynamicObject, dt: number) { 70 | 71 | // calculate factor 72 | if (dt === 0) 73 | return; 74 | 75 | if (dt) 76 | dt /= (1 / 60); 77 | else 78 | dt = 1; 79 | 80 | // TODO: worldsettings is a hack. Find all places which use it in all games 81 | // and come up with a better solution. for example an option sent to the physics Engine 82 | // with a "worldWrap:true" options 83 | // replace with a "worldBounds" parameter to the PhysicsEngine constructor 84 | 85 | let worldSettings = this.gameEngine.worldSettings; 86 | 87 | // TODO: remove this code in version 4: these attributes are deprecated 88 | if (o.isRotatingRight) { o.angle += o.rotationSpeed; } 89 | if (o.isRotatingLeft) { o.angle -= o.rotationSpeed; } 90 | 91 | // TODO: remove this code in version 4: these attributes are deprecated 92 | if (o.angle >= 360) { o.angle -= 360; } 93 | if (o.angle < 0) { o.angle += 360; } 94 | 95 | // TODO: remove this code in version 4: these attributes are deprecated 96 | if (o.isAccelerating) { 97 | let rad = o.angle * (Math.PI / 180); 98 | dv.set(Math.cos(rad), Math.sin(rad)).multiplyScalar(o.acceleration).multiplyScalar(dt); 99 | o.velocity.add(dv); 100 | } 101 | 102 | // apply gravity 103 | if (!o.isStatic) o.velocity.add(this.gravity); 104 | 105 | let velMagnitude = o.velocity.length(); 106 | if ((o.maxSpeed !== null) && (velMagnitude > o.maxSpeed)) { 107 | o.velocity.multiplyScalar(o.maxSpeed / velMagnitude); 108 | } 109 | 110 | o.isAccelerating = false; 111 | o.isRotatingLeft = false; 112 | o.isRotatingRight = false; 113 | 114 | dx.copy(o.velocity).multiplyScalar(dt); 115 | o.position.add(dx); 116 | 117 | o.velocity.multiply(o.friction); 118 | 119 | // wrap around the world edges 120 | if (worldSettings.worldWrap) { 121 | if (o.position.x >= worldSettings.width) { o.position.x -= worldSettings.width; } 122 | if (o.position.y >= worldSettings.height) { o.position.y -= worldSettings.height; } 123 | if (o.position.x < 0) { o.position.x += worldSettings.width; } 124 | if (o.position.y < 0) { o.position.y += worldSettings.height; } 125 | } 126 | } 127 | 128 | // entry point for a single step of the Simple Physics 129 | step(dt: number, objectFilter: (o: GameObject) => boolean): void { 130 | 131 | // each object should advance 132 | let objects = this.gameEngine.world.objects; 133 | for (let objId of Object.keys(objects)) { 134 | 135 | // shadow objects are not re-enacted 136 | let ob = objects[objId]; 137 | if (!objectFilter(ob)) 138 | continue; 139 | 140 | // run the object step 141 | this.objectStep(ob, dt); 142 | } 143 | 144 | // emit event on collision 145 | this.collisionDetection.detect(); 146 | } 147 | } 148 | 149 | export { SimplePhysicsEngine, SimplePhysicsEngineOptions }; 150 | -------------------------------------------------------------------------------- /src/render/AFrameRenderer.ts: -------------------------------------------------------------------------------- 1 | /* globals AFRAME */ 2 | 3 | import { GameEngine } from '../GameEngine.js'; 4 | import Renderer from './Renderer.js'; 5 | import networkedPhysics from './aframe/system.js'; 6 | 7 | declare global { 8 | let AFRAME: any; 9 | } 10 | 11 | /** 12 | * The A-Frame Renderer 13 | */ 14 | class AFrameRenderer extends Renderer { 15 | 16 | protected scene: any; 17 | 18 | /** 19 | * Constructor of the Renderer singleton. 20 | * @param {GameEngine} gameEngine - Reference to the GameEngine instance. 21 | * @param {ClientEngine} clientEngine - Reference to the ClientEngine instance. 22 | */ 23 | constructor(gameEngine: GameEngine) { 24 | super(gameEngine); 25 | 26 | // set up the networkedPhysics as an A-Frame system 27 | networkedPhysics.setGlobals(gameEngine, this); 28 | AFRAME.registerSystem('networked-physics', networkedPhysics); 29 | } 30 | 31 | reportSlowFrameRate() { 32 | this.gameEngine.emit('client__slowFrameRate'); 33 | } 34 | 35 | /** 36 | * Initialize the renderer. 37 | * @return {Promise} Resolves when renderer is ready. 38 | */ 39 | init() { 40 | 41 | let p = super.init(); 42 | 43 | let sceneElArray = document.getElementsByTagName('a-scene'); 44 | if (sceneElArray.length !== 1) { 45 | throw new Error('A-Frame scene element not found'); 46 | } 47 | this.scene = sceneElArray[0]; 48 | 49 | this.gameEngine.on('objectRemoved', (o) => { 50 | o.renderObj.remove(); 51 | }); 52 | 53 | return p; // eslint-disable-line new-cap 54 | } 55 | 56 | /** 57 | * In AFrame, we set the draw method (which is called at requestAnimationFrame) 58 | * to a NO-OP. See tick() instead 59 | */ 60 | draw() {} 61 | 62 | tick(t: number, dt: number) { 63 | super.draw(t, dt); 64 | } 65 | 66 | } 67 | 68 | export default AFrameRenderer; 69 | -------------------------------------------------------------------------------- /src/render/Renderer.ts: -------------------------------------------------------------------------------- 1 | import { GameEngine } from '../GameEngine.js'; 2 | import { ClientEngine } from '../ClientEngine.js'; 3 | let singleton: Renderer; 4 | 5 | const TIME_RESET_THRESHOLD = 100; 6 | 7 | declare global { 8 | let THREE: any; 9 | } 10 | 11 | /** 12 | * The Renderer is the component which must *draw* the game on the client. 13 | * It will be instantiated once on each client, and must implement the draw 14 | * method. The draw method will be invoked on every iteration of the browser's 15 | * render loop. 16 | */ 17 | class Renderer { 18 | 19 | protected gameEngine: GameEngine; 20 | public clientEngine: ClientEngine; 21 | private doReset: boolean; 22 | 23 | static getInstance() { 24 | return singleton; 25 | } 26 | 27 | /** 28 | * Constructor of the Renderer singleton. 29 | * @param {GameEngine} gameEngine - Reference to the GameEngine instance. 30 | */ 31 | constructor(gameEngine) { 32 | this.gameEngine = gameEngine; 33 | this.gameEngine.on('client__stepReset', () => { this.doReset = true; }); 34 | gameEngine.on('objectAdded', this.addObject.bind(this)); 35 | gameEngine.on('objectDestroyed', this.removeObject.bind(this)); 36 | 37 | // the singleton renderer has been created 38 | singleton = this; 39 | } 40 | 41 | /** 42 | * Initialize the renderer. 43 | * @return {Promise} Resolves when renderer is ready. 44 | */ 45 | init(): Promise { 46 | if ((typeof window === 'undefined') || !document) { 47 | console.log('renderer invoked on server side.'); 48 | } 49 | this.gameEngine.emit('client__rendererReady'); 50 | return Promise.resolve(); // eslint-disable-line new-cap 51 | } 52 | 53 | reportSlowFrameRate() { 54 | this.gameEngine.emit('client__slowFrameRate'); 55 | } 56 | 57 | // TODO: t and dt args are not always used (see PixiRenderer) so find a cleaner solution 58 | /** 59 | * The main draw function. This method is called at high frequency, 60 | * at the rate of the render loop. Typically this is 60Hz, in WebVR 90Hz. 61 | * If the client engine has been configured to render-schedule, then this 62 | * method must call the ClientEngine.js's step method. 63 | * 64 | * @param {Number} t - current time (only required in render-schedule mode) 65 | * @param {Number} dt - time elapsed since last draw 66 | */ 67 | draw(t: number, dt: number) { 68 | this.gameEngine.emit('client__draw'); 69 | 70 | if (this.clientEngine.options.scheduler === 'render-schedule') 71 | this.runClientStep(t); 72 | } 73 | 74 | /** 75 | * The main draw function. This method is called at high frequency, 76 | * at the rate of the render loop. Typically this is 60Hz, in WebVR 90Hz. 77 | * 78 | * @param {Number} t - current time 79 | * @param {Number} dt - time elapsed since last draw 80 | */ 81 | runClientStep(t: number) { 82 | let p = this.clientEngine.options.stepPeriod; 83 | let dt = 0; 84 | 85 | // reset step time if we passed a threshold 86 | if (this.doReset || t > this.clientEngine.lastStepTime + TIME_RESET_THRESHOLD) { 87 | this.doReset = false; 88 | this.clientEngine.lastStepTime = t - p / 2; 89 | this.clientEngine.correction = p / 2; 90 | } 91 | 92 | // catch-up missed steps 93 | while (t > this.clientEngine.lastStepTime + p) { 94 | this.clientEngine.step(this.clientEngine.lastStepTime + p, p + this.clientEngine.correction); 95 | this.clientEngine.lastStepTime += p; 96 | this.clientEngine.correction = 0; 97 | } 98 | 99 | // if not ready for a real step yet, return 100 | // this might happen after catch up above 101 | if (t < this.clientEngine.lastStepTime) { 102 | dt = t - this.clientEngine.lastStepTime + this.clientEngine.correction; 103 | if (dt < 0) dt = 0; 104 | this.clientEngine.correction = this.clientEngine.lastStepTime - t; 105 | this.clientEngine.step(t, dt, true); 106 | return; 107 | } 108 | 109 | // render-controlled step 110 | dt = t - this.clientEngine.lastStepTime + this.clientEngine.correction; 111 | this.clientEngine.lastStepTime += p; 112 | this.clientEngine.correction = this.clientEngine.lastStepTime - t; 113 | this.clientEngine.step(t, dt); 114 | } 115 | 116 | /** 117 | * Handle the addition of a new object to the world. 118 | * @param {Object} obj - The object to be added. 119 | */ 120 | addObject(obj) {} 121 | 122 | /** 123 | * Handle the removal of an old object from the world. 124 | * @param {Object} obj - The object to be removed. 125 | */ 126 | removeObject(obj) {} 127 | 128 | /** 129 | * Called when clientEngine has stopped, time to clean up 130 | */ 131 | stop() {} 132 | } 133 | 134 | export default Renderer; 135 | -------------------------------------------------------------------------------- /src/render/ThreeRenderer.ts: -------------------------------------------------------------------------------- 1 | /* global THREE */ 2 | import Renderer from './Renderer.js'; 3 | 4 | 5 | // TODO: I have mixed feelings about this class. It doesn't actually provide 6 | // anything useful. I assume each game will write their own renderer even in THREE. 7 | // we can make it store a list of objects, and provide a Raycaster, and send events. 8 | // But it hijacks the creation of the scene and the THREE.renderer. It doesn't make 9 | // sense to me that the camera and lights are in the derived class, but the scene and 10 | // renderer are in the base class. seems like inheritance-abuse. 11 | export default class ThreeRenderer extends Renderer { 12 | private scene: any; 13 | private camera: any; 14 | private renderer: any; 15 | private raycaster: any; 16 | private THREE: any; 17 | 18 | // constructor 19 | constructor(gameEngine) { 20 | super(gameEngine); 21 | this.scene = null; 22 | this.camera = null; 23 | this.renderer = null; 24 | } 25 | 26 | // setup the 3D scene 27 | init() { 28 | 29 | super.init(); 30 | 31 | // setup the scene 32 | this.scene = new THREE.Scene(); 33 | 34 | // setup the renderer and add the canvas to the body 35 | this.renderer = new THREE.WebGLRenderer({ 36 | antialias: true 37 | }); 38 | document.getElementById('viewport')?.appendChild(this.renderer.domElement); 39 | 40 | // a local raycaster 41 | this.raycaster = new THREE.Raycaster(); 42 | 43 | // TODO: is this still needed? 44 | this.THREE = THREE; 45 | 46 | return Promise.resolve(); 47 | } 48 | 49 | // single step 50 | draw() { 51 | this.renderer.render(this.scene, this.camera); 52 | } 53 | 54 | // add one object 55 | addObject(id) { 56 | // this.scene.add(sphere); 57 | // return sphere; 58 | } 59 | 60 | removeObject(o) { 61 | this.scene.remove(o); 62 | } 63 | } -------------------------------------------------------------------------------- /src/render/aframe/system.ts: -------------------------------------------------------------------------------- 1 | /* global THREE */ 2 | const FRAME_HISTORY_SIZE = 20; 3 | const MAX_SLOW_FRAMES = 10; 4 | 5 | 6 | export default { 7 | schema: { 8 | traceLevel: { default: 4 } 9 | }, 10 | 11 | init: function() { 12 | 13 | // TODO: Sometimes an object is "simple". For example it uses 14 | // existing AFrame assets (an OBJ file and a material) 15 | // in this case, we can auto-generate the DOM element, 16 | // setting the quaternion, position, material, game-object-id 17 | // and obj-model. Same goes for objects which use primitive 18 | // geometric objects. Remember to also remove them. 19 | this.CAMERA_OFFSET_VEC = new THREE.Vector3(0, 5, -10); 20 | this.frameRateHistory = []; 21 | for (let i = 0; i < FRAME_HISTORY_SIZE; i++) 22 | this.frameRateHistory.push(false); 23 | this.frameRateTest = (1000 / 60) * 1.2; 24 | 25 | // capture the chase camera if available 26 | let chaseCameras = document.getElementsByClassName('chaseCamera'); 27 | if (chaseCameras) 28 | this.cameraEl = chaseCameras[0]; 29 | }, 30 | 31 | tick: function(t, dt) { 32 | if (!this.gameEngine) 33 | return; 34 | this.renderer.tick(t, dt); 35 | 36 | let frh = this.frameRateHistory; 37 | frh.push(dt > this.frameRateTest); 38 | frh.shift(); 39 | const slowFrames = frh.filter(x => x); 40 | if (slowFrames.length > MAX_SLOW_FRAMES) { 41 | this.frameRateHistory = frh.map(x => false); 42 | this.renderer.reportSlowFrameRate(); 43 | } 44 | 45 | // for each object in the world, update the a-frame element 46 | this.gameEngine.world.forEachObject((id, o) => { 47 | let el = o.renderEl; 48 | if (el) { 49 | let q = o.quaternion; 50 | let p = o.position; 51 | el.setAttribute('position', `${p.x} ${p.y} ${p.z}`); 52 | el.object3D.quaternion.set(q.x, q.y, q.z, q.w); 53 | 54 | // if a chase camera is configured, update it 55 | if (this.cameraEl && this.gameEngine.playerId === o.playerId) { 56 | let camera = this.cameraEl.object3D.children[0]; 57 | let relativeCameraOffset = this.CAMERA_OFFSET_VEC.clone(); 58 | let cameraOffset = relativeCameraOffset.applyMatrix4(o.renderEl.object3D.matrixWorld); 59 | camera.position.copy(cameraOffset); 60 | camera.lookAt(o.renderEl.object3D.position); 61 | } 62 | 63 | } 64 | }); 65 | }, 66 | 67 | // NOTE: webpack generated incorrect code if you use arrow notation below 68 | // it sets "this" to "undefined" 69 | setGlobals: function(gameEngine, renderer) { 70 | this.gameEngine = gameEngine; 71 | this.renderer = renderer; 72 | } 73 | }; 74 | -------------------------------------------------------------------------------- /src/render/pixi/PixiRenderableComponent.ts: -------------------------------------------------------------------------------- 1 | import GameComponent from '../../serialize/GameComponent.js'; 2 | 3 | declare global { 4 | let PIXI: any; 5 | } 6 | 7 | type PixiRenderableComponentOptions = { 8 | assetName: string, 9 | spriteURL: string, 10 | width: number, 11 | height: number, 12 | onRenderableCreated: (sprite: any, pixiRC: PixiRenderableComponent) => any, 13 | onRender: () => void 14 | } 15 | 16 | export default class PixiRenderableComponent extends GameComponent { 17 | 18 | private options: PixiRenderableComponentOptions; 19 | 20 | constructor(options: PixiRenderableComponentOptions) { 21 | super(); 22 | this.options = options; 23 | } 24 | 25 | /** 26 | * Initial creation of the Pixi renderable 27 | * @returns A pixi container/sprite 28 | */ 29 | createRenderable() { 30 | let sprite; 31 | if (this.options.assetName) { 32 | sprite = new PIXI.Sprite(PIXI.loader.resources[this.options.assetName].texture); 33 | } else if (this.options.spriteURL) { 34 | sprite = new PIXI.Sprite.fromImage(this.options.spriteURL); 35 | } 36 | 37 | if (this.options.width) { 38 | sprite.width = this.options.width; 39 | } 40 | 41 | if (this.options.height) { 42 | sprite.height = this.options.height; 43 | } 44 | 45 | if (this.options.onRenderableCreated) { 46 | sprite = this.options.onRenderableCreated(sprite, this); 47 | } 48 | 49 | return sprite; 50 | } 51 | 52 | /** 53 | * This method gets executed on every render step 54 | * Note - this should only include rendering logic and not game logic 55 | */ 56 | render() { 57 | if (this.options.onRender) { 58 | this.options.onRender(); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/render/pixi/PixiRenderer.ts: -------------------------------------------------------------------------------- 1 | import Renderer from '../Renderer.js'; 2 | import PixiRenderableComponent from './PixiRenderableComponent.js'; 3 | 4 | 5 | type SpriteMap = { 6 | [key: string]: any; 7 | } 8 | /** 9 | * Pixi Renderer 10 | */ 11 | export default class PixiRenderer extends Renderer { 12 | 13 | private isReady: boolean; 14 | private viewportWidth: number; 15 | private viewportHeight: number; 16 | private stage: any; 17 | private layers: any; 18 | private renderer: any; 19 | private sprites: SpriteMap; 20 | private initPromise: Promise; 21 | 22 | /** 23 | * Returns a dictionary of image assets and their paths 24 | * E.G. { 25 | ship: 'assets/ship.png', 26 | missile: 'assets/missile.png', 27 | } 28 | * @returns {{}} 29 | * @constructor 30 | */ 31 | get ASSETPATHS() { 32 | return {}; 33 | } 34 | 35 | constructor(gameEngine) { 36 | super(gameEngine); 37 | this.sprites = {}; 38 | this.isReady = false; 39 | } 40 | 41 | init() { 42 | // prevent calling init twice 43 | if (this.initPromise) return this.initPromise; 44 | 45 | this.viewportWidth = window.innerWidth; 46 | this.viewportHeight = window.innerHeight; 47 | this.stage = new PIXI.Container(); 48 | 49 | // default layers 50 | this.layers = { 51 | base: new PIXI.Container() 52 | }; 53 | 54 | this.stage.addChild(this.layers.base); 55 | 56 | if (document.readyState === 'complete' || document.readyState === 'interactive') { 57 | this.onDOMLoaded(); 58 | } else { 59 | document.addEventListener('DOMContentLoaded', ()=>{ 60 | this.onDOMLoaded(); 61 | }); 62 | } 63 | 64 | this.initPromise = new Promise((resolve, reject)=>{ 65 | let onLoadComplete = () => { 66 | this.isReady = true; 67 | resolve(); 68 | }; 69 | 70 | let resourceList = Object.keys(this.ASSETPATHS).map( x => { 71 | return { 72 | name: x, 73 | url: this.ASSETPATHS[x] 74 | }; 75 | }); 76 | 77 | // make sure there are actual resources in the queue 78 | if (resourceList.length > 0) 79 | PIXI.loader.add(resourceList).load(onLoadComplete); 80 | else 81 | onLoadComplete(); 82 | }); 83 | 84 | return this.initPromise; 85 | } 86 | 87 | onDOMLoaded() { 88 | this.renderer = PIXI.autoDetectRenderer(this.viewportWidth, this.viewportHeight); 89 | document.body.querySelector('.pixiContainer')?.appendChild(this.renderer.view); 90 | } 91 | 92 | draw() { 93 | super.draw(0, 0); 94 | 95 | if (!this.isReady) return; // assets might not have been loaded yet 96 | 97 | for (let objId of Object.keys(this.sprites)) { 98 | let objData = this.gameEngine.world.objects[objId]; 99 | let sprite = this.sprites[objId]; 100 | 101 | if (objData) { 102 | sprite.x = objData.position.x; 103 | sprite.y = objData.position.y; 104 | sprite.rotation = this.gameEngine.world.objects[objId].angle * Math.PI/180; 105 | } 106 | } 107 | 108 | this.renderer.render(this.stage); 109 | } 110 | 111 | addObject(obj) { 112 | if (obj.hasComponent(PixiRenderableComponent)){ 113 | let renderable = obj.getComponent(PixiRenderableComponent); 114 | let sprite = this.sprites[obj.id] = renderable.createRenderable(); 115 | sprite.anchor.set(0.5, 0.5); 116 | sprite.position.set(obj.position.x, obj.position.y); 117 | this.layers.base.addChild(sprite); 118 | } 119 | } 120 | 121 | removeObject(obj) { 122 | if (obj.hasComponent(PixiRenderableComponent)){ 123 | let sprite = this.sprites[obj.id]; 124 | if (sprite) { 125 | this.sprites[obj.id].destroy(); 126 | delete this.sprites[obj.id]; 127 | } 128 | } 129 | } 130 | 131 | } 132 | -------------------------------------------------------------------------------- /src/serialize/BaseTypes.ts: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * The BaseTypes class defines the base types used in Lance. 4 | * These are the types which can be used to define an object's netscheme attributes, 5 | * which can be serialized by lance. 6 | * @example 7 | * netScheme() { 8 | * return { 9 | * strength: { type: BaseTypes.TYPES.FLOAT32 }, 10 | * shield: { type: BaseTypes.TYPES.INT8 }, 11 | * name: { type: BaseTypes.String }, 12 | * backpack: { type: BaseTypes.ClassInstance }, 13 | * coins: { 14 | * type: BaseTypes.List, 15 | * itemType: BaseTypes.TYPES.UINT8 16 | * } 17 | * }; 18 | * } 19 | */ 20 | enum BaseTypes { 21 | Float32 = 'FLOAT32', 22 | Int32 = 'INT32', 23 | Int16 = 'INT16', 24 | Int8 = 'INT8', 25 | UInt8 = 'UINT8', 26 | String = 'STRING', 27 | ClassInstance = 'CLASSINSTANCE', 28 | List = 'List' 29 | } 30 | 31 | 32 | // static TYPES: 'FLOAT32' | 'INT32' | 'INT16' | 'INT8' | 'UINT8' | 'STRING' | 'CLASSINSTANCE' | 'LIST'; 33 | 34 | 35 | /** 36 | * @type {object} 37 | * @property {string} FLOAT32 Seriablizable float 38 | * @property {string} INT32 Seriablizable 32-bit integer 39 | * @property {string} INT16 Seriablizable 16-bit integer 40 | * @property {string} INT8 Seriablizable 8-bit integer 41 | * @property {string} UINT8 Seriablizable unsigned 8-bit integer 42 | * @property {string} STRING Seriablizable string 43 | * @property {string} CLASSINSTANCE Seriablizable class. Make sure you register all the classes included in this way. 44 | * @property {string} LIST Seriablizable list. In the netScheme definition, if an attribute is defined as a list, the itemType should also be defined. 45 | */ 46 | 47 | 48 | export default BaseTypes; 49 | -------------------------------------------------------------------------------- /src/serialize/GameComponent.ts: -------------------------------------------------------------------------------- 1 | import { GameObject } from "./GameObject.js"; 2 | 3 | export default class GameComponent { 4 | private parentObject: GameObject | null; 5 | 6 | constructor() { 7 | /** 8 | * the gameObject this component is attached to. This gets set in the addComponent method 9 | * @member {GameObject} 10 | */ 11 | this.parentObject = null; 12 | } 13 | 14 | static get componentName() { 15 | return this.constructor.name; 16 | } 17 | 18 | netScheme() { 19 | return null; 20 | } 21 | } -------------------------------------------------------------------------------- /src/serialize/GameObject.ts: -------------------------------------------------------------------------------- 1 | import Serializable from './Serializable.js'; 2 | import BaseTypes from './BaseTypes.js'; 3 | import { GameEngine } from '../GameEngine.js'; 4 | 5 | type GameObjectOptions = { id?: number } 6 | type GameObjectProps = { playerId?: number } 7 | 8 | /** 9 | * GameObject is the base class of all game objects. 10 | * It is created only for the purpose of clearly defining the game 11 | * object interface. 12 | * Game developers will use one of the subclasses such as DynamicObject, 13 | * or PhysicalObject. 14 | */ 15 | class GameObject extends Serializable { 16 | 17 | public id: number; 18 | protected gameEngine: GameEngine; 19 | public playerId: number; 20 | private components: { [key: string]: any } 21 | private savedCopy: GameObject | null; 22 | public refreshRenderObject?: () => void; 23 | 24 | netScheme() { 25 | return { 26 | id: { type: BaseTypes.Float32 }, 27 | playerId: { type: BaseTypes.Int16 } 28 | }; 29 | } 30 | 31 | /** 32 | * Creates an instance of a game object. 33 | * @param {GameEngine} gameEngine - the gameEngine this object will be used in 34 | * @param {Number} id - if set, the new instantiated object will be set to this id instead of being generated a new one. Use with caution! 35 | * @param {Object} props - additional properties for creation 36 | * @param {Number} props.playerId - the playerId value of the player who owns this object 37 | */ 38 | constructor(gameEngine: GameEngine, options: GameObjectOptions, props: GameObjectProps) { 39 | super(); 40 | /** 41 | * The gameEngine this object will be used in 42 | * @member {GameEngine} 43 | */ 44 | this.gameEngine = gameEngine; 45 | 46 | /** 47 | * ID of this object's instance. 48 | * There are three cases of instance creation which can occur: 49 | * 1. In the normal case, the constructor is asked to assign an ID which is unique 50 | * across the entire game world, including the server and all the clients. 51 | * 2. In extrapolation mode, the client may have an object instance which does not 52 | * yet exist on the server, these objects are known as shadow objects. Their IDs must 53 | * be allocated from a different range. 54 | * 3. Also, temporary objects are created on the client side each time a sync is received. 55 | * These are used for interpolation purposes and as bending targets of position, velocity, 56 | * angular velocity, and orientation. In this case the id will be set to null. 57 | * @member {Number} 58 | */ 59 | if (options && options.id) 60 | this.id = options.id; 61 | else 62 | this.id = this.gameEngine.world.getNewId(); 63 | 64 | /** 65 | * playerId of player who created this object 66 | * @member {Number} 67 | */ 68 | this.playerId = (props && props.playerId) ? props.playerId : 0; 69 | 70 | this.components = {}; 71 | } 72 | 73 | /** 74 | * Called after the object is added to to the game world. 75 | * This is the right place to add renderer sub-objects, physics sub-objects 76 | * and any other resources that should be created 77 | * @param {GameEngine} gameEngine the game engine 78 | */ 79 | onAddToWorld(gameEngine: GameEngine) {} 80 | 81 | /** 82 | * Called after the object is removed from game-world. 83 | * This is where renderer sub-objects and any other resources should be freed 84 | * @param {GameEngine} gameEngine the game engine 85 | */ 86 | onRemoveFromWorld(gameEngine: GameEngine) {} 87 | 88 | /** 89 | * Formatted textual description of the game object. 90 | * @return {String} description - a string description 91 | */ 92 | toString() { 93 | return `game-object[${this.id}]`; 94 | } 95 | 96 | /** 97 | * Formatted textual description of the game object's current bending properties. 98 | * @return {String} description - a string description 99 | */ 100 | bendingToString() { 101 | return 'no bending'; 102 | } 103 | 104 | saveState(other?: GameObject) { 105 | this.savedCopy = (new ( this.constructor)(this.gameEngine, { id: null })); 106 | this.savedCopy?.syncTo(other ? other : this); 107 | } 108 | /** 109 | * Bending is defined as the amount of error correction that will be applied 110 | * on the client side to a given object's physical attributes, incrementally, 111 | * by the time the next server broadcast is expected to arrive. 112 | * 113 | * When this percentage is 0.0, the client always ignores the server object's value. 114 | * When this percentage is 1.0, the server object's attributes will be applied in full. 115 | * 116 | * The GameObject bending attribute is implemented as a getter, and can provide 117 | * distinct values for position, velocity, angle, and angularVelocity. 118 | * And in each case, you can also provide overrides for local objects, 119 | * these attributes will be called, respectively, positionLocal, velocityLocal, 120 | * angleLocal, angularVelocityLocal. 121 | * 122 | * @example 123 | * get bending() { 124 | * return { 125 | * position: { percent: 1.0, min: 0.0 }, 126 | * velocity: { percent: 0.0, min: 0.0 }, 127 | * angularVelocity: { percent: 0.0 }, 128 | * angleLocal: { percent: 1.0 } 129 | * } 130 | * }; 131 | * 132 | * @memberof GameObject 133 | * @member {Object} bending 134 | */ 135 | get bending() : any { 136 | return {}; 137 | } 138 | 139 | // TODO: 140 | // rather than pass worldSettings on each bend, they could 141 | // be passed in on the constructor just once. 142 | bendToCurrentState(bending: number, worldSettings: any, isLocal: boolean, bendingIncrements: number) { 143 | if (this.savedCopy) { 144 | this.bendToCurrent(this.savedCopy, bending, worldSettings, isLocal, bendingIncrements); 145 | } 146 | this.savedCopy = null; 147 | } 148 | 149 | bendToCurrent(original: GameObject, bending: number, worldSettings: any, isLocal: boolean, bendingIncrements: number) { 150 | } 151 | 152 | /** 153 | * synchronize this object to the state of an other object, by copying all the netscheme variables. 154 | * This is used by the synchronizer to create temporary objects, and must be implemented by all sub-classes as well. 155 | * @param {GameObject} other the other object to synchronize to 156 | */ 157 | syncTo(other: GameObject) { 158 | super.syncTo(other); 159 | this.playerId = other.playerId; 160 | } 161 | 162 | // copy physical attributes to physics sub-object 163 | refreshToPhysics() {} 164 | 165 | // copy physical attributes from physics sub-object 166 | refreshFromPhysics() {} 167 | 168 | // apply a single bending increment 169 | applyIncrementalBending(stepDesc: any) { } 170 | 171 | // clean up resources 172 | destroy() {} 173 | 174 | addComponent(componentInstance: any) { 175 | componentInstance.parentObject = this; 176 | this.components[componentInstance.constructor.name] = componentInstance; 177 | 178 | // a gameEngine might not exist if this class is instantiated by the serializer 179 | if (this.gameEngine) { 180 | this.gameEngine.emit('componentAdded', this, componentInstance); 181 | } 182 | } 183 | 184 | removeComponent(componentName) { 185 | // todo cleanup of the component ? 186 | delete this.components[componentName]; 187 | 188 | // a gameEngine might not exist if this class is instantiated by the serializer 189 | if (this.gameEngine) { 190 | this.gameEngine.emit('componentRemoved', this, componentName); 191 | } 192 | } 193 | 194 | /** 195 | * Check whether this game object has a certain component 196 | * @param {Object} componentClass the comp 197 | * @return {Boolean} true if the gameObject contains this component 198 | */ 199 | hasComponent(componentClass) { 200 | return componentClass.name in this.components; 201 | } 202 | 203 | getComponent(componentClass) { 204 | return this.components[componentClass.name]; 205 | } 206 | 207 | } 208 | 209 | export { GameObject, GameObjectOptions, GameObjectProps }; 210 | -------------------------------------------------------------------------------- /src/serialize/NetScheme.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The netScheme type defines the base types used in Lance. 3 | * These are the types which can be used to define an object's netscheme attributes, 4 | * which can be serialized by lance. 5 | * @example 6 | * netScheme() { 7 | * return { 8 | * strength: { type: BaseTypes.TYPES.FLOAT32 }, 9 | * shield: { type: BaseTypes.TYPES.INT8 }, 10 | * name: { type: BaseTypes.String }, 11 | * backpack: { type: BaseTypes.ClassInstance }, 12 | * coins: { 13 | * type: BaseTypes.List, 14 | * itemType: BaseTypes.TYPES.UINT8 15 | * } 16 | * }; 17 | * } 18 | */ 19 | type NetScheme = { 20 | [key: string] : { 'type': string, 'itemType'?: string } 21 | } 22 | 23 | export { NetScheme } 24 | -------------------------------------------------------------------------------- /src/serialize/Quaternion.ts: -------------------------------------------------------------------------------- 1 | import Serializable from './Serializable.js'; 2 | import BaseTypes from './BaseTypes.js'; 3 | import { ThreeVector } from './ThreeVector.js'; 4 | 5 | const SHOW_AS_AXIS_ANGLE = true; 6 | const MAX_DEL_THETA = 0.2; 7 | 8 | /** 9 | * A Quaternion is a geometric object which can be used to 10 | * represent a three-dimensional rotation. 11 | */ 12 | class Quaternion extends Serializable { 13 | private w: number; 14 | private x: number; 15 | private y: number; 16 | private z: number; 17 | 18 | netScheme() { 19 | return { 20 | w: { type: BaseTypes.Float32 }, 21 | x: { type: BaseTypes.Float32 }, 22 | y: { type: BaseTypes.Float32 }, 23 | z: { type: BaseTypes.Float32 } 24 | }; 25 | } 26 | 27 | /** 28 | * Creates an instance of a Quaternion. 29 | * @param {Number} w - first value 30 | * @param {Number} x - second value 31 | * @param {Number} y - third value 32 | * @param {Number} z - fourth value 33 | * @return {Quaternion} v - the new Quaternion 34 | */ 35 | constructor(w: number, x: number, y: number, z: number) { 36 | super(); 37 | this.w = w; 38 | this.x = x; 39 | this.y = y; 40 | this.z = z; 41 | 42 | return this; 43 | } 44 | 45 | /** 46 | * Formatted textual description of the Quaternion. 47 | * @return {String} description 48 | */ 49 | toString(): string { 50 | function round3(x) { return Math.round(x * 1000) / 1000; } 51 | if (SHOW_AS_AXIS_ANGLE) { 52 | let axisAngle = this.toAxisAngle(); 53 | return `[${round3(axisAngle.angle)},${axisAngle.axis.toString()}]`; 54 | } 55 | return `[${round3(this.w)}, ${round3(this.x)}, ${round3(this.y)}, ${round3(this.z)}]`; 56 | } 57 | 58 | /** 59 | * copy values from another quaternion into this quaternion 60 | * 61 | * @param {Quaternion} sourceObj the quaternion to copy from 62 | * @return {Quaternion} returns self 63 | */ 64 | copy(sourceObj: Quaternion): this { 65 | this.set(sourceObj.w, sourceObj.x, sourceObj.y, sourceObj.z); 66 | return this; 67 | } 68 | 69 | /** 70 | * set quaternion values 71 | * 72 | * @param {Number} w w-value 73 | * @param {Number} x x-value 74 | * @param {Number} y y-value 75 | * @param {Number} z z-value 76 | * @return {Quaternion} returns self 77 | */ 78 | set(w: number, x: number, y: number, z: number): this { 79 | this.w = w; 80 | this.x = x; 81 | this.y = y; 82 | this.z = z; 83 | 84 | return this; 85 | } 86 | 87 | /** 88 | * return an axis-angle representation of this quaternion 89 | * 90 | * @return {Object} contains two attributes: axis (ThreeVector) and angle. 91 | */ 92 | toAxisAngle() : { axis: ThreeVector, angle: number} { 93 | 94 | // assuming quaternion normalised then w is less than 1, so term always positive. 95 | let axis = new ThreeVector(1, 0, 0); 96 | this.normalize(); 97 | let angle = 2 * Math.acos(this.w); 98 | let s = Math.sqrt(1 - this.w * this.w); 99 | if (s > 0.001) { 100 | let divS = 1 / s; 101 | axis.x = this.x * divS; 102 | axis.y = this.y * divS; 103 | axis.z = this.z * divS; 104 | } 105 | if (s > Math.PI) { 106 | s -= 2 * Math.PI; 107 | } 108 | return { axis, angle }; 109 | } 110 | 111 | normalize(): this { 112 | let l = Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z + this.w * this.w); 113 | if (l === 0) { 114 | this.x = 0; 115 | this.y = 0; 116 | this.z = 0; 117 | this.w = 0; 118 | } else { 119 | l = 1 / l; 120 | this.x *= l; 121 | this.y *= l; 122 | this.z *= l; 123 | this.w *= l; 124 | } 125 | 126 | return this; 127 | } 128 | 129 | /** 130 | * set the values of this quaternion from an axis/angle representation 131 | * 132 | * @param {ThreeVector} axis The axis 133 | * @param {Number} angle angle in radians 134 | * @return {Quaternion} returns self 135 | */ 136 | setFromAxisAngle(axis: ThreeVector, angle: number): this { 137 | 138 | if (angle < 0) 139 | angle += Math.PI * 2; 140 | let halfAngle = angle * 0.5; 141 | let s = Math.sin(halfAngle); 142 | this.x = axis.x * s; 143 | this.y = axis.y * s; 144 | this.z = axis.z * s; 145 | this.w = Math.cos(halfAngle); 146 | 147 | return this; 148 | } 149 | 150 | /** 151 | * conjugate the quaternion, in-place 152 | * 153 | * @return {Quaternion} returns self 154 | */ 155 | conjugate(): this { 156 | this.x *= -1; 157 | this.y *= -1; 158 | this.z *= -1; 159 | return this; 160 | } 161 | 162 | /* eslint-disable */ 163 | /** 164 | * multiply this quaternion by another, in-place 165 | * 166 | * @param {Quaternion} other The other quaternion 167 | * @return {Quaternion} returns self 168 | */ 169 | multiply(other: Quaternion): this { 170 | let aw = this.w, ax = this.x, ay = this.y, az = this.z; 171 | let bw = other.w, bx = other.x, by = other.y, bz = other.z; 172 | 173 | this.x = ax * bw + aw * bx + ay * bz - az * by; 174 | this.y = ay * bw + aw * by + az * bx - ax * bz; 175 | this.z = az * bw + aw * bz + ax * by - ay * bx; 176 | this.w = aw * bw - ax * bx - ay * by - az * bz; 177 | 178 | return this; 179 | } 180 | /* eslint-enable */ 181 | 182 | /* eslint-disable */ 183 | /** 184 | * Apply in-place slerp (spherical linear interpolation) to this quaternion, 185 | * towards another quaternion. 186 | * 187 | * @param {Quaternion} target The target quaternion 188 | * @param {Number} bending The percentage to interpolate 189 | * @return {Quaternion} returns self 190 | */ 191 | slerp(target: Quaternion, bending: number): this { 192 | 193 | if (bending <= 0) return this; 194 | if (bending >= 1) return this.copy(target); 195 | 196 | let aw = this.w, ax = this.x, ay = this.y, az = this.z; 197 | let bw = target.w, bx = target.x, by = target.y, bz = target.z; 198 | 199 | let cosHalfTheta = aw*bw + ax*bx + ay*by + az*bz; 200 | if (cosHalfTheta < 0) { 201 | this.set(-bw, -bx, -by, -bz); 202 | cosHalfTheta = -cosHalfTheta; 203 | } else { 204 | this.copy(target); 205 | } 206 | 207 | if (cosHalfTheta >= 1.0) { 208 | this.set(aw, ax, ay, az); 209 | return this; 210 | } 211 | 212 | let sqrSinHalfTheta = 1.0 - cosHalfTheta*cosHalfTheta; 213 | if (sqrSinHalfTheta < Number.EPSILON) { 214 | let s = 1 - bending; 215 | this.set(s*aw + bending*this.w, s*ax + bending*this.x, s*ay + bending*this.y, s*az + bending*this.z); 216 | return this.normalize(); 217 | } 218 | 219 | let sinHalfTheta = Math.sqrt(sqrSinHalfTheta); 220 | let halfTheta = Math.atan2(sinHalfTheta, cosHalfTheta); 221 | let delTheta = bending * halfTheta; 222 | if (Math.abs(delTheta) > MAX_DEL_THETA) 223 | delTheta = MAX_DEL_THETA * Math.sign(delTheta); 224 | let ratioA = Math.sin(halfTheta - delTheta)/sinHalfTheta; 225 | let ratioB = Math.sin(delTheta)/sinHalfTheta; 226 | this.set(aw*ratioA + this.w*ratioB, 227 | ax*ratioA + this.x*ratioB, 228 | ay*ratioA + this.y*ratioB, 229 | az*ratioA + this.z*ratioB); 230 | return this; 231 | } 232 | /* eslint-enable */ 233 | } 234 | 235 | export default Quaternion; 236 | -------------------------------------------------------------------------------- /src/serialize/Serializable.ts: -------------------------------------------------------------------------------- 1 | import Utils from '../lib/Utils.js'; 2 | import BaseTypes from './BaseTypes.js'; 3 | import { NetScheme } from './NetScheme.js'; 4 | import Serializer from './Serializer.js'; 5 | 6 | 7 | type SerializableOptions = { 8 | dataBuffer: any, 9 | bufferOffset: number, 10 | dry: boolean 11 | } 12 | 13 | type SerializedObj = { 14 | dataBuffer: ArrayBuffer, 15 | bufferOffset: number 16 | } 17 | 18 | class Serializable { 19 | 20 | public classId: number; 21 | 22 | constructor() {} 23 | 24 | netScheme(): NetScheme { 25 | return {}; 26 | } 27 | 28 | /** 29 | * Class can be serialized using either: 30 | * - a class based netScheme 31 | * - an instance based netScheme 32 | * - completely dynamically (not implemented yet) 33 | * 34 | * @param {Object} serializer - Serializer instance 35 | * @param {Object} [options] - Options object 36 | * @param {Object} options.dataBuffer [optional] - Data buffer to write to. If null a new data buffer will be created 37 | * @param {Number} options.bufferOffset [optional] - The buffer data offset to start writing at. Default: 0 38 | * @param {Boolean} options.dry [optional] - Does not actually write to the buffer (useful to gather serializeable size) 39 | * @return {Object} the serialized object. Contains attributes: dataBuffer - buffer which contains the serialized data; bufferOffset - offset where the serialized data starts. 40 | */ 41 | serialize(serializer: Serializer, options: SerializableOptions): SerializedObj { 42 | 43 | options = Object.assign({ 44 | bufferOffset: 0 45 | }, options); 46 | 47 | let netScheme; 48 | let dataBuffer; 49 | let dataView; 50 | let classId = 0; 51 | let bufferOffset = options.bufferOffset; 52 | let localBufferOffset = 0; // used for counting the bufferOffset 53 | 54 | // instance classId 55 | // TODO: when is this.classId ever populated? 56 | if (this.classId) { 57 | classId = this.classId; 58 | } else { 59 | classId = Utils.hashStr(this.constructor.name); 60 | } 61 | 62 | netScheme = this.netScheme(); 63 | 64 | // TODO: currently we serialize every node twice, once to calculate the size 65 | // of the buffers and once to write them out. This can be reduced to 66 | // a single pass by starting with a large (and static) ArrayBuffer and 67 | // recursively building it up. 68 | // buffer has one Uint8Array for class id, then payload 69 | if (options.dataBuffer == null && options.dry != true) { 70 | let bufferSize = this.serialize(serializer, { dry: true, dataBuffer: null, bufferOffset: 0 }).bufferOffset; 71 | dataBuffer = new ArrayBuffer(bufferSize); 72 | } else { 73 | dataBuffer = options.dataBuffer; 74 | } 75 | 76 | if (options.dry != true) { 77 | dataView = new DataView(dataBuffer); 78 | // first set the id of the class, so that the deserializer can fetch information about it 79 | dataView.setUint8(bufferOffset + localBufferOffset, classId); 80 | } 81 | 82 | // advance the offset counter 83 | localBufferOffset += Uint8Array.BYTES_PER_ELEMENT; 84 | 85 | if (netScheme) { 86 | for (let property of Object.keys(netScheme).sort()) { 87 | 88 | // write the property to buffer 89 | if (options.dry != true) { 90 | serializer.writeDataView(dataView, this[property], bufferOffset + localBufferOffset, netScheme[property]); 91 | } 92 | 93 | if (netScheme[property].type === BaseTypes.String) { 94 | // derive the size of the string 95 | localBufferOffset += Uint16Array.BYTES_PER_ELEMENT; 96 | if (this[property] !== null && this[property] !== undefined) 97 | localBufferOffset += this[property].length * Uint16Array.BYTES_PER_ELEMENT; 98 | } else if (netScheme[property].type === BaseTypes.ClassInstance) { 99 | // derive the size of the included class 100 | let objectInstanceBufferOffset = this[property].serialize(serializer, { dry: true }).bufferOffset; 101 | localBufferOffset += objectInstanceBufferOffset; 102 | } else if (netScheme[property].type === BaseTypes.List) { 103 | // derive the size of the list 104 | // list starts with number of elements 105 | localBufferOffset += Uint16Array.BYTES_PER_ELEMENT; 106 | 107 | for (let item of this[property]) { 108 | // todo inelegant, currently doesn't support list of lists 109 | if (netScheme[property].itemType === BaseTypes.ClassInstance) { 110 | let listBufferOffset = item.serialize(serializer, { dry: true }).bufferOffset; 111 | localBufferOffset += listBufferOffset; 112 | } else if (netScheme[property].itemType === BaseTypes.String) { 113 | // size includes string length plus double-byte characters 114 | localBufferOffset += Uint16Array.BYTES_PER_ELEMENT * (1 + item.length); 115 | } else { 116 | localBufferOffset += serializer.getTypeByteSize(netScheme[property].itemType); 117 | } 118 | } 119 | } else { 120 | // advance offset 121 | localBufferOffset += serializer.getTypeByteSize(netScheme[property].type); 122 | } 123 | 124 | } 125 | } else { 126 | // TODO no netScheme, dynamic class 127 | } 128 | 129 | return { dataBuffer, bufferOffset: localBufferOffset }; 130 | } 131 | 132 | // build a clone of this object with pruned strings (if necessary) 133 | prunedStringsClone(serializer: Serializer, prevObject: Serializable) { 134 | 135 | if (!prevObject) return this; 136 | prevObject = serializer.deserialize(prevObject).obj; 137 | 138 | // get list of string properties which changed 139 | // @ts-ignore 140 | let netScheme = this.netScheme(); 141 | let isString = p => netScheme[p].type === BaseTypes.String; 142 | let hasChanged = p => prevObject[p] !== this[p]; 143 | let changedStrings = Object.keys(netScheme).filter(isString).filter(hasChanged); 144 | if (changedStrings.length == 0) return this; 145 | 146 | // build a clone with pruned strings 147 | let objectClass = serializer.registeredClasses[this.classId]; 148 | let prunedCopy = new objectClass(null, { id: null }); 149 | // let prunedCopy = new this.constructor(null, { id: null }); 150 | for (let p of Object.keys(netScheme)) 151 | prunedCopy[p] = changedStrings.indexOf(p) < 0 ? this[p] : null; 152 | 153 | return prunedCopy; 154 | } 155 | 156 | syncTo(other: Serializable) { 157 | let netScheme = this.netScheme(); 158 | for (let p of Object.keys(netScheme)) { 159 | 160 | // ignore classes and lists 161 | if (netScheme[p].type === BaseTypes.List || netScheme[p].type === BaseTypes.ClassInstance) 162 | continue; 163 | 164 | // strings might be pruned 165 | if (netScheme[p].type === BaseTypes.String) { 166 | if (typeof other[p] === 'string') this[p] = other[p]; 167 | continue; 168 | } 169 | 170 | // all other values are copied 171 | this[p] = other[p]; 172 | } 173 | } 174 | 175 | } 176 | 177 | export default Serializable; 178 | -------------------------------------------------------------------------------- /src/serialize/ThreeVector.ts: -------------------------------------------------------------------------------- 1 | import Serializable from './Serializable.js'; 2 | import BaseTypes from './BaseTypes.js'; 3 | import { TwoVectorBendingOptions } from './TwoVector.js'; 4 | 5 | interface ThreeVectorBendingOptions extends TwoVectorBendingOptions {}; 6 | 7 | /** 8 | * A ThreeVector is a geometric object which is completely described 9 | * by three values. 10 | */ 11 | class ThreeVector extends Serializable { 12 | 13 | public x: number; 14 | public y: number; 15 | public z: number; 16 | 17 | netScheme() { 18 | return { 19 | x: { type: BaseTypes.Float32 }, 20 | y: { type: BaseTypes.Float32 }, 21 | z: { type: BaseTypes.Float32 } 22 | }; 23 | } 24 | 25 | /** 26 | * Creates an instance of a ThreeVector. 27 | * @param {Number} x - first value 28 | * @param {Number} y - second value 29 | * @param {Number} z - second value 30 | * @return {ThreeVector} v - the new ThreeVector 31 | */ 32 | constructor(x: number, y: number, z: number) { 33 | super(); 34 | this.x = x; 35 | this.y = y; 36 | this.z = z; 37 | 38 | return this; 39 | } 40 | 41 | /** 42 | * Formatted textual description of the ThreeVector. 43 | * @return {String} description 44 | */ 45 | toString(): string { 46 | function round3(x) { return Math.round(x * 1000) / 1000; } 47 | return `[${round3(this.x)}, ${round3(this.y)}, ${round3(this.z)}]`; 48 | } 49 | 50 | /** 51 | * Multiply this ThreeVector by a scalar 52 | * 53 | * @param {Number} s the scale 54 | * @return {ThreeVector} returns self 55 | */ 56 | multiplyScalar(s: number): ThreeVector { 57 | this.x *= s; 58 | this.y *= s; 59 | this.z *= s; 60 | return this; 61 | } 62 | 63 | /** 64 | * Get vector length 65 | * 66 | * @return {Number} length of this vector 67 | */ 68 | length(): number { 69 | return Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z); 70 | } 71 | 72 | /** 73 | * Add other vector to this vector 74 | * 75 | * @param {ThreeVector} other the other vector 76 | * @return {ThreeVector} returns self 77 | */ 78 | add(other: ThreeVector): ThreeVector { 79 | this.x += other.x; 80 | this.y += other.y; 81 | this.z += other.z; 82 | return this; 83 | } 84 | 85 | /** 86 | * Subtract other vector from this vector 87 | * 88 | * @param {ThreeVector} other the other vector 89 | * @return {ThreeVector} returns self 90 | */ 91 | subtract(other: ThreeVector): ThreeVector { 92 | this.x -= other.x; 93 | this.y -= other.y; 94 | this.z -= other.z; 95 | return this; 96 | } 97 | 98 | /** 99 | * Normalize this vector, in-place 100 | * 101 | * @return {ThreeVector} returns self 102 | */ 103 | normalize(): ThreeVector { 104 | this.multiplyScalar(1 / this.length()); 105 | return this; 106 | } 107 | 108 | /** 109 | * Copy values from another ThreeVector into this ThreeVector 110 | * 111 | * @param {ThreeVector} sourceObj the other vector 112 | * @return {ThreeVector} returns self 113 | */ 114 | copy(sourceObj: ThreeVector): ThreeVector { 115 | this.x = sourceObj.x; 116 | this.y = sourceObj.y; 117 | this.z = sourceObj.z; 118 | return this; 119 | } 120 | 121 | /** 122 | * Set ThreeVector values 123 | * 124 | * @param {Number} x x-value 125 | * @param {Number} y y-value 126 | * @param {Number} z z-value 127 | * @return {ThreeVector} returns self 128 | */ 129 | set(x: number, y: number, z: number): ThreeVector { 130 | this.x = x; 131 | this.y = y; 132 | this.z = z; 133 | return this; 134 | } 135 | 136 | /** 137 | * Create a clone of this vector 138 | * 139 | * @return {ThreeVector} returns clone 140 | */ 141 | clone(): ThreeVector { 142 | return new ThreeVector(this.x, this.y, this.z); 143 | } 144 | 145 | /** 146 | * Apply in-place lerp (linear interpolation) to this ThreeVector 147 | * towards another ThreeVector 148 | * @param {ThreeVector} target the target vector 149 | * @param {Number} p The percentage to interpolate 150 | * @return {ThreeVector} returns self 151 | */ 152 | lerp(target: ThreeVector, p: number): ThreeVector { 153 | this.x += (target.x - this.x) * p; 154 | this.y += (target.y - this.y) * p; 155 | this.z += (target.z - this.z) * p; 156 | return this; 157 | } 158 | 159 | /** 160 | * Get bending Delta Vector 161 | * towards another ThreeVector 162 | * @param {ThreeVector} target the target vector 163 | * @param {Object} options bending options 164 | * @param {Number} options.increments number of increments 165 | * @param {Number} options.percent The percentage to bend 166 | * @param {Number} options.min No less than this value 167 | * @param {Number} options.max No more than this value 168 | * @return {ThreeVector} returns new Incremental Vector 169 | */ 170 | getBendingDelta(target: ThreeVector, options: ThreeVectorBendingOptions): ThreeVector { 171 | let increment = target.clone(); 172 | increment.subtract(this); 173 | increment.multiplyScalar(options.percent); 174 | 175 | // check for max case 176 | if ((options.max && increment.length() > options.max) || 177 | (options.max && increment.length() < options.min)) { 178 | return new ThreeVector(0, 0, 0); 179 | } 180 | 181 | // divide into increments 182 | increment.multiplyScalar(1 / options.increments); 183 | 184 | return increment; 185 | } 186 | } 187 | 188 | export { ThreeVector, ThreeVectorBendingOptions }; 189 | -------------------------------------------------------------------------------- /src/serialize/TwoVector.ts: -------------------------------------------------------------------------------- 1 | import Serializable from './Serializable.js'; 2 | import BaseTypes from './BaseTypes.js'; 3 | 4 | interface TwoVectorBendingOptions { 5 | increments: number; 6 | percent: number; 7 | min: number; 8 | max: number; 9 | } 10 | 11 | /** 12 | * A TwoVector is a geometric object which is completely described 13 | * by two values. 14 | */ 15 | class TwoVector extends Serializable { 16 | public x: number; // TODO: instead of public, have it getter (readonly) 17 | public y: number; 18 | 19 | netScheme() { 20 | return { 21 | x: { type: BaseTypes.Float32 }, 22 | y: { type: BaseTypes.Float32 } 23 | }; 24 | } 25 | 26 | /** 27 | * Creates an instance of a TwoVector. 28 | * @param {Number} x - first value 29 | * @param {Number} y - second value 30 | * @return {TwoVector} v - the new TwoVector 31 | */ 32 | constructor(x: number, y: number) { 33 | super(); 34 | this.x = x; 35 | this.y = y; 36 | 37 | return this; 38 | } 39 | 40 | /** 41 | * Formatted textual description of the TwoVector. 42 | * @return {String} description 43 | */ 44 | toString(): string { 45 | function round3(x) { return Math.round(x * 1000) / 1000; } 46 | return `[${round3(this.x)}, ${round3(this.y)}]`; 47 | } 48 | 49 | /** 50 | * Set TwoVector values 51 | * 52 | * @param {Number} x x-value 53 | * @param {Number} y y-value 54 | * @return {TwoVector} returns self 55 | */ 56 | set(x: number, y: number): this { 57 | this.x = x; 58 | this.y = y; 59 | return this; 60 | } 61 | 62 | multiply(other: TwoVector): this { 63 | this.x *= other.x; 64 | this.y *= other.y; 65 | 66 | return this; 67 | } 68 | 69 | /** 70 | * Multiply this TwoVector by a scalar 71 | * 72 | * @param {Number} s the scale 73 | * @return {TwoVector} returns self 74 | */ 75 | multiplyScalar(s: number): this { 76 | this.x *= s; 77 | this.y *= s; 78 | 79 | return this; 80 | } 81 | 82 | /** 83 | * Add other vector to this vector 84 | * 85 | * @param {TwoVector} other the other vector 86 | * @return {TwoVector} returns self 87 | */ 88 | add(other: TwoVector): this { 89 | this.x += other.x; 90 | this.y += other.y; 91 | 92 | return this; 93 | } 94 | 95 | /** 96 | * Subtract other vector to this vector 97 | * 98 | * @param {TwoVector} other the other vector 99 | * @return {TwoVector} returns self 100 | */ 101 | subtract(other: TwoVector): this { 102 | this.x -= other.x; 103 | this.y -= other.y; 104 | 105 | return this; 106 | } 107 | 108 | /** 109 | * Get vector length 110 | * 111 | * @return {Number} length of this vector 112 | */ 113 | length(): number { 114 | return Math.sqrt(this.x * this.x + this.y * this.y); 115 | } 116 | 117 | /** 118 | * Normalize this vector, in-place 119 | * 120 | * @return {TwoVector} returns self 121 | */ 122 | normalize(): this { 123 | this.multiplyScalar(1 / this.length()); 124 | return this; 125 | } 126 | 127 | /** 128 | * Copy values from another TwoVector into this TwoVector 129 | * 130 | * @param {TwoVector} sourceObj the other vector 131 | * @return {TwoVector} returns self 132 | */ 133 | copy(sourceObj: TwoVector): this { 134 | this.x = sourceObj.x; 135 | this.y = sourceObj.y; 136 | 137 | return this; 138 | } 139 | 140 | /** 141 | * Create a clone of this vector 142 | * 143 | * @return {TwoVector} returns clone 144 | */ 145 | clone(): TwoVector { 146 | return new TwoVector(this.x, this.y); 147 | } 148 | 149 | /** 150 | * Apply in-place lerp (linear interpolation) to this TwoVector 151 | * towards another TwoVector 152 | * @param {TwoVector} target the target vector 153 | * @param {Number} p The percentage to interpolate 154 | * @return {TwoVector} returns self 155 | */ 156 | lerp(target: TwoVector, p: number): this { 157 | this.x += (target.x - this.x) * p; 158 | this.y += (target.y - this.y) * p; 159 | 160 | return this; 161 | } 162 | 163 | /** 164 | * Get bending Delta Vector 165 | * towards another TwoVector 166 | * @param {TwoVector} target the target vector 167 | * @param {Object} options bending options 168 | * @param {Number} options.increments number of increments 169 | * @param {Number} options.percent The percentage to bend 170 | * @param {Number} options.min No less than this value 171 | * @param {Number} options.max No more than this value 172 | * @return {TwoVector} returns new Incremental Vector 173 | */ 174 | getBendingDelta(target: TwoVector, options: TwoVectorBendingOptions): TwoVector { 175 | let increment = target.clone(); 176 | increment.subtract(this); 177 | increment.multiplyScalar(options.percent); 178 | 179 | // check for max case 180 | if (((typeof options.max === 'number') && increment.length() > options.max) || 181 | ((typeof options.min === 'number') && increment.length() < options.min)) { 182 | return new TwoVector(0, 0); 183 | } 184 | 185 | // divide into increments 186 | increment.multiplyScalar(1 / options.increments); 187 | 188 | return increment; 189 | } 190 | } 191 | 192 | export { TwoVector, TwoVectorBendingOptions }; 193 | -------------------------------------------------------------------------------- /src/syncStrategies/FrameSyncStrategy.ts: -------------------------------------------------------------------------------- 1 | import { ClientEngine } from '../ClientEngine.js'; 2 | import { GameWorld } from '../GameWorld.js'; 3 | import NetworkTransmitter from '../network/NetworkTransmitter.js'; 4 | import { Sync, SyncStrategy, SyncStrategyOptions } from './SyncStrategy.js'; 5 | 6 | 7 | const defaults = { 8 | worldBufferLength: 60, 9 | clientStepLag: 0 10 | }; 11 | 12 | class FrameSyncStrategy extends SyncStrategy { 13 | 14 | constructor(options: SyncStrategyOptions) { 15 | super(options); 16 | } 17 | 18 | // apply a new sync 19 | applySync(sync: Sync, required: boolean) { 20 | 21 | this.needFirstSync = false; 22 | this.gameEngine.trace.debug(() => 'framySync applying sync'); 23 | let world = this.gameEngine.world; 24 | 25 | for (let ids of Object.keys(sync.syncObjects)) { 26 | let ev = sync.syncObjects[ids][0]; 27 | let curObj = world.objects[ev.objectInstance.id]; 28 | if (curObj) { 29 | curObj.syncTo(ev.objectInstance); 30 | } else { 31 | this.addNewObject(ev.objectInstance.id, ev.objectInstance); 32 | } 33 | } 34 | 35 | // destroy objects 36 | for (let objIdStr of Object.keys(world.objects)) { 37 | 38 | let objId = Number(objIdStr); 39 | let objEvents = sync.syncObjects[objId]; 40 | 41 | // if this was a full sync, and we did not get a corresponding object, 42 | // remove the local object 43 | if (sync.fullUpdate && !objEvents && objId < this.gameEngine.options.clientIDSpace) { 44 | this.gameEngine.removeObjectFromWorld(objId); 45 | continue; 46 | } 47 | 48 | if (!objEvents || objId >= this.gameEngine.options.clientIDSpace) 49 | continue; 50 | 51 | // if we got an objectDestroy event, destroy the object 52 | objEvents.forEach((e) => { 53 | if (NetworkTransmitter.getNetworkEvent(e) == 'objectDestroy') this.gameEngine.removeObjectFromWorld(objId); 54 | }); 55 | } 56 | 57 | return SyncStrategy.SYNC_APPLIED; 58 | } 59 | 60 | } 61 | 62 | export { FrameSyncStrategy } 63 | -------------------------------------------------------------------------------- /src/syncStrategies/InterpolateStrategy.ts: -------------------------------------------------------------------------------- 1 | import { ClientEngine } from '../ClientEngine.js'; 2 | import { GameWorld } from '../GameWorld.js'; 3 | import NetworkTransmitter from '../network/NetworkTransmitter.js'; 4 | import { Sync, SyncStrategy, SyncStrategyOptions } from './SyncStrategy.js'; 5 | 6 | const defaults = { 7 | clientStepHold: 6, 8 | localObjBending: 1.0, // amount of bending towards position of sync object 9 | remoteObjBending: 1.0, // amount of bending towards position of sync object 10 | bendingIncrements: 6, // the bending should be applied increments (how many steps for entire bend) 11 | reflect: false 12 | }; 13 | 14 | interface InterpolateSyncStrategyOptions extends SyncStrategyOptions { 15 | localObjBending: number; 16 | remoteObjBending: number; 17 | bendingIncrements: number; 18 | } 19 | 20 | class InterpolateStrategy extends SyncStrategy { 21 | 22 | static STEP_DRIFT_THRESHOLDS = { 23 | onServerSync: { MAX_LEAD: -8, MAX_LAG: 16 }, // max step lead/lag allowed after every server sync 24 | onEveryStep: { MAX_LEAD: -4, MAX_LAG: 24 }, // max step lead/lag allowed at every step 25 | clientReset: 40 // if we are behind this many steps, just reset the step counter 26 | }; 27 | 28 | private interpolateOptions: InterpolateSyncStrategyOptions; 29 | 30 | constructor(interpolateOptions: InterpolateSyncStrategyOptions) { 31 | 32 | super(interpolateOptions); 33 | this.interpolateOptions = Object.assign({}, defaults, interpolateOptions); 34 | 35 | 36 | this.gameEngine.ignoreInputs = true; // client side engine ignores inputs 37 | this.gameEngine.ignorePhysics = true; // client side engine ignores physics 38 | } 39 | 40 | // apply a new sync 41 | applySync(sync: Sync, required: boolean): string { 42 | 43 | // if sync is in the past we cannot interpolate to it 44 | if (!required && sync.stepCount <= this.gameEngine.world.stepCount) { 45 | return SyncStrategy.SYNC_APPLIED; 46 | } 47 | 48 | this.gameEngine.trace.debug(() => 'interpolate applying sync'); 49 | // 50 | // scan all the objects in the sync 51 | // 52 | // 1. if the object exists locally, sync to the server object 53 | // 2. if the object is new, just create it 54 | // 55 | this.needFirstSync = false; 56 | let world: GameWorld = this.gameEngine.world; 57 | for (let ids of Object.keys(sync.syncObjects)) { 58 | 59 | // TODO: we are currently taking only the first event out of 60 | // the events that may have arrived for this object 61 | let ev = sync.syncObjects[ids][0]; 62 | let curObj = world.objects[ev.objectInstance.id]; 63 | 64 | if (curObj) { 65 | 66 | // case 1: this object already exists locally 67 | this.gameEngine.trace.trace(() => `object before syncTo: ${curObj.toString()}`); 68 | curObj.saveState(); 69 | curObj.syncTo(ev.objectInstance); 70 | this.gameEngine.trace.trace(() => `object after syncTo: ${curObj.toString()} synced to step[${ev.stepCount}]`); 71 | 72 | } else { 73 | 74 | // case 2: object does not exist. create it now 75 | this.addNewObject(ev.objectInstance.id, ev.objectInstance); 76 | } 77 | } 78 | 79 | // 80 | // bend back to original state 81 | // 82 | for (let objId of Object.keys(world.objects)) { 83 | 84 | let obj = world.objects[objId]; 85 | let isLocal = (obj.playerId == this.gameEngine.playerId); // eslint-disable-line eqeqeq 86 | let bending = isLocal ? this.interpolateOptions.localObjBending : this.interpolateOptions.remoteObjBending; 87 | obj.bendToCurrentState(bending, this.gameEngine.worldSettings, isLocal, this.interpolateOptions.bendingIncrements); 88 | if (typeof obj.refreshRenderObject === 'function') 89 | obj.refreshRenderObject(); 90 | this.gameEngine.trace.trace(() => `object[${objId}] ${obj.bendingToString()}`); 91 | } 92 | 93 | // destroy objects 94 | // TODO: use world.forEachObject((id, ob) => {}); 95 | // TODO: identical code is in InterpolateStrategy 96 | 97 | for (let objIdStr of Object.keys(world.objects)) { 98 | 99 | let objId = Number(objIdStr); 100 | let objEvents = sync.syncObjects[objId]; 101 | 102 | // if this was a full sync, and we did not get a corresponding object, 103 | // remove the local object 104 | if (sync.fullUpdate && !objEvents && objId < this.gameEngine.options.clientIDSpace) { 105 | this.gameEngine.removeObjectFromWorld(objId); 106 | continue; 107 | } 108 | 109 | if (!objEvents || objId >= this.gameEngine.options.clientIDSpace) 110 | continue; 111 | 112 | // if we got an objectDestroy event, destroy the object 113 | objEvents.forEach((e) => { 114 | if (NetworkTransmitter.getNetworkEvent(e) == 'objectDestroy') this.gameEngine.removeObjectFromWorld(objId); 115 | }); 116 | } 117 | 118 | return SyncStrategy.SYNC_APPLIED; 119 | } 120 | } 121 | 122 | export { InterpolateStrategy, InterpolateSyncStrategyOptions } -------------------------------------------------------------------------------- /src/syncStrategies/SyncStrategy.ts: -------------------------------------------------------------------------------- 1 | import { ClientEngine } from '../ClientEngine.js'; 2 | import { GameEngine } from '../GameEngine.js'; 3 | import NetworkTransmitter from '../network/NetworkTransmitter.js'; 4 | import Serializable from '../serialize/Serializable.js'; 5 | 6 | interface SyncStrategyOptions {} 7 | 8 | type Sync = { 9 | stepCount: number, 10 | fullUpdate: boolean, 11 | required: boolean, 12 | syncObjects: { [key: number]: Serializable[] }, // all events in the sync indexed by the id of the object involved 13 | syncSteps: { [key: number]: { [key: string]: Serializable[] } } // all events in the sync indexed by the step on which they occurred 14 | }; 15 | 16 | class SyncStrategy { 17 | 18 | protected clientEngine: ClientEngine; 19 | protected gameEngine: GameEngine; 20 | protected needFirstSync: boolean; 21 | private requiredSyncs: Sync[]; 22 | protected lastSync: Sync | null; 23 | private options: SyncStrategyOptions; 24 | static SYNC_APPLIED = 'SYNC_APPLIED'; 25 | static STEP_DRIFT_THRESHOLDS = { 26 | onServerSync: { MAX_LEAD: 1, MAX_LAG: 3 }, // max step lead/lag allowed after every server sync 27 | onEveryStep: { MAX_LEAD: 7, MAX_LAG: 8 }, // max step lead/lag allowed at every step 28 | clientReset: 20 // if we are behind this many steps, just reset the step counter 29 | }; 30 | 31 | constructor(inputOptions: SyncStrategyOptions) { 32 | this.needFirstSync = true; 33 | this.options = Object.assign({}, inputOptions); 34 | this.requiredSyncs = []; 35 | } 36 | 37 | initClient(clientEngine: ClientEngine) { 38 | this.clientEngine = clientEngine; 39 | this.gameEngine = clientEngine.gameEngine; 40 | this.gameEngine.on('client__postStep', this.syncStep.bind(this)); 41 | this.gameEngine.on('client__syncReceived', this.collectSync.bind(this)); 42 | } 43 | 44 | // collect a sync and its events 45 | // maintain a "lastSync" member which describes the last sync we received from 46 | // the server. the lastSync object contains: 47 | // - syncObjects: all events in the sync indexed by the id of the object involved 48 | // - syncSteps: all events in the sync indexed by the step on which they occurred 49 | // - objCount 50 | // - eventCount 51 | // - stepCount 52 | collectSync(e) { 53 | 54 | // on first connect we need to wait for a full world update 55 | if (this.needFirstSync) { 56 | if (!e.fullUpdate) 57 | return; 58 | } else { 59 | 60 | // TODO: there is a problem below in the case where the client is 10 steps behind the server, 61 | // and the syncs that arrive are always in the future and never get processed. To address this 62 | // we may need to store more than one sync. 63 | 64 | // ignore syncs which are older than the latest 65 | if (this.lastSync && this.lastSync.stepCount && this.lastSync.stepCount > e.stepCount) 66 | return; 67 | } 68 | 69 | // before we overwrite the last sync, check if it was a required sync 70 | // syncs that create or delete objects are saved because they must be applied. 71 | if (this.lastSync && this.lastSync.required) { 72 | this.requiredSyncs.push(this.lastSync); 73 | } 74 | 75 | // build new sync object 76 | let lastSync: Sync = this.lastSync = { 77 | stepCount: e.stepCount, 78 | fullUpdate: e.fullUpdate, 79 | required: false, 80 | syncObjects: {}, 81 | syncSteps: {} 82 | }; 83 | 84 | e.syncEvents.forEach(sEvent => { 85 | 86 | // keep a reference of events by object id 87 | if (sEvent.objectInstance) { 88 | let objectId = sEvent.objectInstance.id; 89 | if (!lastSync.syncObjects[objectId]) lastSync.syncObjects[objectId] = []; 90 | lastSync.syncObjects[objectId].push(sEvent); 91 | } 92 | 93 | // keep a reference of events by step 94 | let stepCount = sEvent.stepCount; 95 | let eventName = NetworkTransmitter.getNetworkEvent(sEvent); 96 | if (eventName === 'objectDestroy' || eventName === 'objectCreate') 97 | lastSync.required = true; 98 | 99 | if (!lastSync.syncSteps[stepCount]) lastSync.syncSteps[stepCount] = {}; 100 | if (!lastSync.syncSteps[stepCount][eventName]) lastSync.syncSteps[stepCount][eventName] = []; 101 | lastSync.syncSteps[stepCount][eventName].push(sEvent); 102 | }); 103 | 104 | let eventCount = e.syncEvents.length; 105 | let objCount = (Object.keys(lastSync.syncObjects)).length; 106 | let stepCount = (Object.keys(lastSync.syncSteps)).length; 107 | this.gameEngine.trace.debug(() => `sync contains ${objCount} objects ${eventCount} events ${stepCount} steps`); 108 | } 109 | 110 | // add an object to our world 111 | addNewObject(objId, newObj) { 112 | 113 | let curObj = new newObj.constructor(this.gameEngine, { 114 | id: objId 115 | }); 116 | 117 | // enforce object implementations of syncTo 118 | if (!curObj.__proto__.hasOwnProperty('syncTo')) { 119 | throw `GameObject of type ${curObj.class} does not implement the syncTo() method, which must copy the netscheme`; 120 | } 121 | 122 | curObj.syncTo(newObj); 123 | this.gameEngine.addObjectToWorld(curObj); 124 | if (this.clientEngine.options.verbose) 125 | console.log(`adding new object ${curObj}`); 126 | 127 | return curObj; 128 | } 129 | 130 | applySync(sync: Sync, required: boolean): string { 131 | return SyncStrategy.SYNC_APPLIED; 132 | } 133 | 134 | // sync to step, by applying bending, and applying the latest sync 135 | syncStep(stepDesc) { 136 | 137 | // apply incremental bending 138 | this.gameEngine.world.forEachObject((id, o) => { 139 | if (typeof o.applyIncrementalBending === 'function') { 140 | o.applyIncrementalBending(stepDesc); 141 | o.refreshToPhysics(); 142 | } 143 | }); 144 | 145 | // apply all pending required syncs 146 | while (this.requiredSyncs.length) { 147 | 148 | let requiredStep = this.requiredSyncs[0].stepCount; 149 | 150 | // if we haven't reached the corresponding step, it's too soon to apply syncs 151 | if (requiredStep > this.gameEngine.world.stepCount) 152 | return; 153 | 154 | this.gameEngine.trace.trace(() => `applying a required sync ${requiredStep}`); 155 | this.applySync(this.requiredSyncs[0], true); 156 | this.requiredSyncs.shift(); 157 | } 158 | 159 | // apply the sync and delete it on success 160 | if (this.lastSync) { 161 | let rc = this.applySync(this.lastSync, false); 162 | if (rc === SyncStrategy.SYNC_APPLIED) // TODO: replace above return with a boolean 163 | this.lastSync = null; 164 | } 165 | } 166 | } 167 | 168 | export { SyncStrategy, SyncStrategyOptions, Sync } -------------------------------------------------------------------------------- /test/EndToEnd/multiplayer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('chai').assert; 4 | const incheon = require('../../'); 5 | const testGameServer = require('./testGame/server'); 6 | const testGameClient = require('./testGame/client'); 7 | 8 | let state = { 9 | server: null, 10 | clients: [], 11 | numClients: 0 12 | }; 13 | 14 | // set all clients in a certain direction 15 | function setDirection(direction, value) { 16 | state.clients.forEach(c => { 17 | c.clientEngine.pressedKeys[direction] = value; 18 | }); 19 | } 20 | 21 | describe('multiplayer-game', function() { 22 | 23 | it('start server', function(done) { 24 | let s = state.server = testGameServer.start(); 25 | assert.instanceOf(s.gameEngine, incheon.GameEngine); 26 | assert.instanceOf(s.serverEngine, incheon.ServerEngine); 27 | assert.instanceOf(s.physicsEngine, incheon.physics.PhysicsEngine); 28 | done(); 29 | }); 30 | 31 | it('start five clients', function(done) { 32 | while (state.numClients < 5) { 33 | let c = state.clients[state.numClients++] = testGameClient.start(); 34 | assert.instanceOf(c.gameEngine, incheon.GameEngine); 35 | assert.instanceOf(c.clientEngine, incheon.ClientEngine); 36 | assert.instanceOf(c.physicsEngine, incheon.physics.PhysicsEngine); 37 | } 38 | done(); 39 | }); 40 | 41 | it('everybody go up', function(done) { 42 | this.timeout(10000); 43 | setDirection('up', true); 44 | setTimeout(() => { 45 | setDirection('up', false); 46 | setDirection('down', true); 47 | setTimeout(() => { 48 | setDirection('down', false); 49 | done(); 50 | }, 4000); 51 | }, 4000); 52 | }); 53 | 54 | }); 55 | -------------------------------------------------------------------------------- /test/EndToEnd/testGame/client.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const incheon = require('../../../'); 4 | const MyClientEngine = require('./src/client/MyClientEngine.js'); 5 | const MyGameEngine = require('./src/common/MyGameEngine'); 6 | const SimplePhysicsEngine = incheon.physics.SimplePhysicsEngine; 7 | const search = (typeof location === 'undefined') ? {} : location.search; 8 | const qsOptions = require('query-string').parse(search); 9 | 10 | // default options, overwritten by query-string options 11 | // is sent to both game engine and client engine 12 | const defaults = { 13 | traceLevel: 1, 14 | delayInputCount: 3, 15 | clientIDSpace: 1000000, 16 | syncOptions: { 17 | sync: qsOptions.sync || 'extrapolate', 18 | localObjBending: 0.0, 19 | remoteObjBending: 0.8, 20 | bendingIncrements: 6 21 | } 22 | }; 23 | let options = Object.assign(defaults, qsOptions); 24 | 25 | // create a client engine and a game engine 26 | const physicsEngine = new SimplePhysicsEngine(); 27 | const gameOptions = Object.assign({ physicsEngine, traceLevel: 0 }, options); 28 | const gameEngine = new MyGameEngine(gameOptions); 29 | const clientEngine = new MyClientEngine(gameEngine, options); 30 | 31 | function start() { 32 | clientEngine.start(); 33 | return { clientEngine, gameEngine, gameOptions, physicsEngine }; 34 | } 35 | 36 | // in a browser environment we auto-start 37 | // in a node environment we return a start method 38 | if (typeof document === 'undefined') { 39 | module.exports = { start }; 40 | } else { 41 | document.addEventListener('DOMContentLoaded', start); 42 | } 43 | -------------------------------------------------------------------------------- /test/EndToEnd/testGame/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const express = require('express'); 4 | const socketIO = require('socket.io'); 5 | const path = require('path'); 6 | const incheon = require('../../../'); 7 | 8 | const PORT = process.env.PORT || 3000; 9 | const INDEX = path.join(__dirname, './index.html'); 10 | 11 | // define routes and socket 12 | const server = express(); 13 | server.get('/', function(req, res) { res.sendFile(INDEX); }); 14 | server.use('/', express.static(path.join(__dirname, '.'))); 15 | let requestHandler = server.listen(PORT, () => console.log(`Listening on ${PORT}`)); 16 | const io = socketIO(requestHandler); 17 | 18 | // Game Server 19 | const MyServerEngine = require(path.join(__dirname, 'src/server/MyServerEngine.js')); 20 | const MyGameEngine = require(path.join(__dirname, 'src/common/MyGameEngine.js')); 21 | const SimplePhysicsEngine = incheon.physics.SimplePhysicsEngine; 22 | 23 | // Game Instances 24 | const physicsEngine = new SimplePhysicsEngine(); 25 | const gameEngine = new MyGameEngine({ physicsEngine, traceLevel: 0 }); 26 | const serverEngine = new MyServerEngine(io, gameEngine, { debug: {}, updateRate: 6 }); 27 | 28 | // start the game 29 | function start() { 30 | serverEngine.start(); 31 | return { gameEngine, serverEngine, physicsEngine }; 32 | } 33 | 34 | module.exports = { start }; 35 | -------------------------------------------------------------------------------- /test/EndToEnd/testGame/src/client/MyClientEngine.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const incheon = require('../../../../../'); 4 | const ClientEngine = incheon.ClientEngine; 5 | const MyRenderer = require('../client/MyRenderer'); 6 | 7 | class MyClientEngine extends ClientEngine { 8 | 9 | constructor(gameEngine, options) { 10 | super(gameEngine, options); 11 | 12 | // initialize renderer 13 | this.renderer = new MyRenderer(gameEngine); 14 | this.serializer.registerClass(require('../common/PlayerAvatar')); 15 | this.gameEngine.on('client.preStep', this.preStep.bind(this)); 16 | 17 | // keep a reference for key press state 18 | this.pressedKeys = { 19 | down: false, 20 | up: false, 21 | left: false, 22 | right: false, 23 | space: false 24 | }; 25 | } 26 | 27 | // our pre-step is to process all inputs 28 | preStep() { 29 | 30 | if (this.pressedKeys.up) { 31 | this.sendInput('up', { movement: true }); 32 | } 33 | 34 | if (this.pressedKeys.down) { 35 | this.sendInput('down', { movement: true }); 36 | } 37 | 38 | if (this.pressedKeys.left) { 39 | this.sendInput('left', { movement: true }); 40 | } 41 | 42 | if (this.pressedKeys.right) { 43 | this.sendInput('right', { movement: true }); 44 | } 45 | 46 | if (this.pressedKeys.space) { 47 | this.sendInput('space', { movement: true }); 48 | } 49 | } 50 | 51 | } 52 | 53 | module.exports = MyClientEngine; 54 | -------------------------------------------------------------------------------- /test/EndToEnd/testGame/src/client/MyRenderer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const incheon = require('../../../../../'); 4 | const Renderer = incheon.render.Renderer; 5 | 6 | class MyRenderer extends Renderer { 7 | 8 | constructor(gameEngine, clientEngine) { 9 | super(gameEngine, clientEngine); 10 | this.sprites = {}; 11 | } 12 | 13 | draw() { 14 | super.draw(); 15 | } 16 | 17 | addObject(objData, options) { 18 | let sprite = {}; 19 | 20 | // add this object to the renderer: 21 | // if (objData instanceof PlayerAvatar) { 22 | // ... 23 | // } 24 | 25 | Object.assign(sprite, options); 26 | this.sprites[objData.id] = sprite; 27 | 28 | return sprite; 29 | } 30 | 31 | removeObject(obj) { 32 | obj.destroy(); 33 | delete this.sprites[obj.id]; 34 | } 35 | 36 | } 37 | 38 | module.exports = MyRenderer; 39 | -------------------------------------------------------------------------------- /test/EndToEnd/testGame/src/common/MyGameEngine.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const incheon = require('../../../../../'); 3 | const GameEngine = incheon.GameEngine; 4 | 5 | class MyGameEngine extends GameEngine { 6 | 7 | constructor(options) { 8 | super(options); 9 | } 10 | 11 | start() { 12 | 13 | super.start(); 14 | 15 | this.worldSettings = { 16 | width: 400, 17 | height: 400 18 | }; 19 | } 20 | 21 | processInput(inputData, playerId) { 22 | 23 | super.processInput(inputData, playerId); 24 | 25 | // get the player tied to the player socket 26 | let player; 27 | for (let objId in this.world.objects) { 28 | if (this.world.objects[objId].playerId == playerId) { 29 | player = this.world.objects[objId]; 30 | break; 31 | } 32 | } 33 | 34 | if (player) { 35 | console.log(`player ${playerId} pressed ${inputData.input}`); 36 | if (inputData.input === 'up') { 37 | player.isMovingUp = true; 38 | } else if (inputData.input === 'down') { 39 | player.isMovingDown = true; 40 | } else if (inputData.input === 'right') { 41 | player.isRotatingRight = true; 42 | } else if (inputData.input === 'left') { 43 | player.isRotatingLeft = true; 44 | } else if (inputData.input === 'space') { 45 | this.fire(player, inputData.messageIndex); 46 | this.emit('fire'); 47 | } 48 | } 49 | } 50 | } 51 | 52 | module.exports = MyGameEngine; 53 | -------------------------------------------------------------------------------- /test/EndToEnd/testGame/src/common/PlayerAvatar.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const incheon = require('../../../../../'); 3 | const DynamicObject = incheon.serialize.DynamicObject; 4 | 5 | class PlayerAvatar extends DynamicObject { 6 | 7 | netScheme() { 8 | return Object.assign({}, super.netScheme); 9 | } 10 | 11 | constructor(id, x, y) { 12 | super(id, x, y); 13 | this.class = PlayerAvatar; 14 | } 15 | } 16 | 17 | module.exports = PlayerAvatar; 18 | -------------------------------------------------------------------------------- /test/EndToEnd/testGame/src/server/MyServerEngine.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const incheon = require('../../../../../'); 3 | const ServerEngine = incheon.ServerEngine; 4 | 5 | class MyServerEngine extends ServerEngine { 6 | 7 | constructor(io, gameEngine, inputOptions) { 8 | super(io, gameEngine, inputOptions); 9 | this.serializer.registerClass(require('../common/PlayerAvatar')); 10 | } 11 | 12 | start() { 13 | super.start(); 14 | } 15 | 16 | onPlayerConnected(socket) { 17 | super.onPlayerConnected(socket); 18 | } 19 | 20 | onPlayerDisconnected(socketId, playerId) { 21 | super.onPlayerDisconnected(socketId, playerId); 22 | 23 | delete this.gameEngine.world.objects[playerId]; 24 | } 25 | } 26 | 27 | module.exports = MyServerEngine; 28 | -------------------------------------------------------------------------------- /test/SimplePhysics/HSHG.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const should = require('should'); 4 | const BaseTypes = require('../../src/serialize/BaseTypes'); 5 | const DynamicObject = require('../../src/serialize/DynamicObject'); 6 | const HSHG = require('../../src/physics/SimplePhysics/HSHG'); 7 | 8 | class TestObject extends DynamicObject { 9 | 10 | static get netScheme(){ 11 | return { 12 | height: BaseTypes.TYPES.UINT16, 13 | width: BaseTypes.TYPES.UINT16 14 | }; 15 | } 16 | 17 | constructor(){ 18 | super(); 19 | this.width = 100; 20 | this.height = 100; 21 | }; 22 | } 23 | 24 | let grid = new HSHG(); 25 | 26 | let obj1 = new TestObject(1); 27 | let obj2 = new TestObject(2); 28 | 29 | grid.addObject(obj1); 30 | grid.addObject(obj2); 31 | 32 | describe('HSHG collision detection', function() { 33 | 34 | it('No collision 1', function() { 35 | obj1.position.x = 0; 36 | obj2.position.x = 101; 37 | grid.update(); 38 | let collisionPairs = grid.queryForCollisionPairs(); 39 | collisionPairs.length.should.equal(0); 40 | }); 41 | 42 | it('Partial overlap Collision', function() { 43 | obj1.position.x = 0; 44 | obj2.position.x = 50; 45 | grid.update(); 46 | let collisionPairs = grid.queryForCollisionPairs(); 47 | collisionPairs.length.should.equal(1); 48 | }); 49 | 50 | it('No collision 2', function() { 51 | obj1.position.x = 0; 52 | obj2.position.x = 101; 53 | grid.update(); 54 | let collisionPairs = grid.queryForCollisionPairs(); 55 | collisionPairs.length.should.equal(0); 56 | }); 57 | 58 | 59 | it('Full overlap collision', function() { 60 | obj1.position.x = 0; 61 | obj2.position.x = 0; 62 | obj2.position.width = 50; 63 | obj2.position.height = 50; 64 | grid.update(); 65 | let collisionPairs = grid.queryForCollisionPairs(); 66 | collisionPairs.length.should.equal(1); 67 | }); 68 | 69 | }); 70 | -------------------------------------------------------------------------------- /test/SimplePhysics/HSHGGameEngine.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const should = require('should'); 4 | const BaseTypes = require('../../src/serialize/BaseTypes'); 5 | const DynamicObject = require('../../src/serialize/DynamicObject'); 6 | const GameEngine = require('../../src/GameEngine'); 7 | const SimplePhysicsEngine = require('../../src/physics/SimplePhysicsEngine'); 8 | 9 | class TestObject extends DynamicObject { 10 | 11 | static get netScheme(){ 12 | return Object.assign({ 13 | height: BaseTypes.TYPES.UINT16, 14 | width: BaseTypes.TYPES.UINT16 15 | }, super.netScheme); 16 | } 17 | 18 | constructor(id){ 19 | super(id); 20 | this.width = 10; 21 | this.height = 10; 22 | }; 23 | } 24 | 25 | let gameEngine = new GameEngine(); 26 | gameEngine.physicsEngine = new SimplePhysicsEngine({ 27 | gameEngine: gameEngine, 28 | collisionOptions: { 29 | type: 'HSHG' 30 | } 31 | }); 32 | 33 | gameEngine.start(); 34 | 35 | let obj1 = new TestObject(1); 36 | let obj2 = new TestObject(2); 37 | gameEngine.addObjectToWorld(obj1); 38 | gameEngine.addObjectToWorld(obj2); 39 | 40 | describe('HSHG Game Engine collision detection', function() { 41 | obj1.position.x = 5; 42 | obj2.position.x = 20; 43 | obj2.velocity.x = -5; 44 | console.log(obj1.position, obj2.position); 45 | 46 | 47 | it('Step 0 - No collision ', function() { 48 | // console.log(obj1.position, obj2.position); 49 | gameEngine.physicsEngine.collisionDetection.areObjectsColliding(obj1, obj2).should.equal(false); 50 | }); 51 | 52 | it('Step 1 - collision ', function() { 53 | gameEngine.once('collisionStart', pairObj => { 54 | gameEngine.world.stepCount.should.equal(1); 55 | }); 56 | gameEngine.step(false); 57 | console.log(obj1.position, obj2.position); 58 | gameEngine.physicsEngine.collisionDetection.areObjectsColliding(obj1, obj2).should.equal(true); 59 | }); 60 | 61 | it('Step 2 - collision ', function() { 62 | gameEngine.step(false); 63 | console.log(obj1.position, obj2.position); 64 | gameEngine.physicsEngine.collisionDetection.areObjectsColliding(obj1, obj2).should.equal(true); 65 | }); 66 | 67 | 68 | it('Step 3 - collision ', function() { 69 | gameEngine.step(false); 70 | console.log(obj1.position, obj2.position); 71 | gameEngine.physicsEngine.collisionDetection.areObjectsColliding(obj1, obj2).should.equal(true); 72 | }); 73 | 74 | it('Step 4 - collision ', function() { 75 | gameEngine.step(false); 76 | console.log(obj1.position, obj2.position); 77 | gameEngine.physicsEngine.collisionDetection.areObjectsColliding(obj1, obj2).should.equal(true); 78 | }); 79 | 80 | it('Step 5 - collision ', function() { 81 | gameEngine.step(false); 82 | console.log(obj1.position, obj2.position); 83 | gameEngine.physicsEngine.collisionDetection.areObjectsColliding(obj1, obj2).should.equal(true); 84 | }); 85 | 86 | it('Step 6 - no collision ', function() { 87 | gameEngine.once('collisionStop', pairObj => { 88 | gameEngine.world.stepCount.should.equal(6); 89 | }); 90 | console.log(obj1.position, obj2.position); 91 | gameEngine.step(false); 92 | gameEngine.physicsEngine.collisionDetection.areObjectsColliding(obj1, obj2).should.equal(false); 93 | }); 94 | 95 | }); 96 | -------------------------------------------------------------------------------- /test/serializer/list.js: -------------------------------------------------------------------------------- 1 | // Serializer must be loaded first before Serializable because of circular deps 2 | import Serializer from '../../src/serialize/Serializer.js'; 3 | import Serializable from '../../src/serialize/Serializable.js'; 4 | import BaseTypes from '../../src/serialize/BaseTypes.js'; 5 | 6 | class TestObject extends Serializable { 7 | 8 | static get netScheme() { 9 | return { 10 | playerAges: { 11 | type: BaseTypes.List, 12 | itemType: BaseTypes.TYPES.UINT8 13 | }, 14 | }; 15 | } 16 | 17 | constructor(playerAges) { 18 | super(); 19 | this.playerAges = playerAges; 20 | } 21 | } 22 | 23 | var serializer = new Serializer(); 24 | 25 | var testObject = new TestObject([1, 2, 3]); 26 | serializer.registerClass(TestObject); 27 | testObject.class = TestObject; 28 | 29 | describe('List serialization/deserialization', function() { 30 | let serializedTestObject = null; 31 | let deserializedTestObject = null; 32 | 33 | describe('primitives', function() { 34 | 35 | it('Serialize list', function() { 36 | serializedTestObject = testObject.serialize(serializer); 37 | 38 | }); 39 | 40 | it('Deserialize list', function() { 41 | deserializedTestObject = serializer.deserialize(serializedTestObject.dataBuffer); 42 | deserializedTestObject.byteOffset.should.equal(6); 43 | deserializedTestObject.obj.playerAges.should.be.instanceof(Array).and.have.lengthOf(3); 44 | deserializedTestObject.obj.playerAges[0].should.equal(1); 45 | deserializedTestObject.obj.playerAges[1].should.equal(2); 46 | deserializedTestObject.obj.playerAges[2].should.equal(3); 47 | }); 48 | 49 | }); 50 | 51 | }); 52 | -------------------------------------------------------------------------------- /test/serializer/primitives.js: -------------------------------------------------------------------------------- 1 | import should from 'should'; 2 | 3 | import Serializer from '../../src/serialize/Serializer.js'; 4 | import Serializable from '../../src/serialize/Serializable.js'; 5 | import BaseTypes from '../../src/serialize/BaseTypes.js'; 6 | 7 | class TestObject extends Serializable { 8 | 9 | static get netScheme(){ 10 | return { 11 | float32: { type: BaseTypes.TYPES.FLOAT32 }, 12 | int32: { type: BaseTypes.TYPES.INT32 }, 13 | int16: { type: BaseTypes.TYPES.INT16 }, 14 | int8: { type: BaseTypes.TYPES.INT8 }, 15 | uint8: { type: BaseTypes.TYPES.UINT8 } 16 | } 17 | } 18 | 19 | constructor(data){ 20 | super(); 21 | if (data) { 22 | this.float32 = data.float32; 23 | this.int32 = data.int32; 24 | this.int16 = data.int16; 25 | this.int8 = data.int8; 26 | this.uint8 = data.uint8; 27 | } 28 | }; 29 | } 30 | 31 | var serializer = new Serializer(); 32 | 33 | var testObject = new TestObject({ 34 | float32: 1/3, 35 | int32: 2147483646, 36 | int16: 32766, 37 | int8: 126, 38 | uint8: 254 39 | }); 40 | serializer.registerClass(TestObject); 41 | testObject.class = TestObject; 42 | 43 | describe('Object with primitives serialization/deserialization', function() { 44 | let serializedTestObject, deserializedTestObject; 45 | 46 | it('Serialize object', function () { 47 | serializedTestObject = testObject.serialize(serializer); 48 | 49 | }); 50 | it('Deserialize object', function () { 51 | deserializedTestObject = serializer.deserialize(serializedTestObject.dataBuffer); 52 | deserializedTestObject.byteOffset.should.equal(1 + 4 + 4 + 2 + 1 + 1); 53 | //float precision is capped 54 | (deserializedTestObject.obj.float32 - testObject.float32).should.be.lessThan(0.00000001); 55 | deserializedTestObject.obj.int32.should.be.equal(testObject.int32); 56 | deserializedTestObject.obj.int16.should.be.equal(testObject.int16); 57 | deserializedTestObject.obj.int8.should.be.equal(testObject.int8); 58 | deserializedTestObject.obj.uint8.should.be.equal(testObject.uint8); 59 | }); 60 | 61 | }); 62 | -------------------------------------------------------------------------------- /test/serializer/string.js: -------------------------------------------------------------------------------- 1 | import should from 'should'; 2 | 3 | import BaseTypes from '../../src/serialize/BaseTypes.js'; 4 | import Serializer from '../../src/serialize/Serializer.js'; 5 | import Serializable from '../../src/serialize/Serializable.js'; 6 | 7 | class TestObject extends Serializable { 8 | 9 | static get netScheme() { 10 | return { 11 | helloString: { type: BaseTypes.String }, 12 | color: { type: BaseTypes.String } 13 | }; 14 | } 15 | 16 | constructor(helloString) { 17 | super(); 18 | this.helloString = helloString; 19 | this.color = 'RED'; 20 | } 21 | } 22 | 23 | var serializer = new Serializer(); 24 | 25 | serializer.registerClass(TestObject); 26 | let testObjects = [new TestObject('hello'), new TestObject('hello'), new TestObject('goodbye')]; 27 | testObjects.map(t => { 28 | t.class = TestObject; 29 | return null; 30 | }); 31 | 32 | describe('List serialization/deserialization', function() { 33 | let serializedTestObjects; 34 | let deserializedTestObjects; 35 | 36 | describe('primitives', function() { 37 | 38 | it('Serialize object with string', function() { 39 | serializedTestObjects = testObjects.map(t => { return t.serialize(serializer); }); 40 | }); 41 | 42 | it('Deserialize list', function() { 43 | deserializedTestObjects = serializedTestObjects.map(s => { return serializer.deserialize(s.dataBuffer); }); 44 | deserializedTestObjects[0].obj.helloString.should.equal('hello'); 45 | deserializedTestObjects[1].obj.helloString.should.equal('hello'); 46 | deserializedTestObjects[2].obj.helloString.should.equal('goodbye'); 47 | }); 48 | 49 | }); 50 | 51 | }); 52 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "module": "node16", 5 | "removeComments": true, 6 | "preserveConstEnums": true, 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["src/**/*"], 10 | "exclude": ["**/*.spec.ts"] 11 | } -------------------------------------------------------------------------------- /utils/lanceInfo.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const express = require('express'); 3 | 4 | const PORT = process.env.PORT || 2000; 5 | 6 | // define routes and socket 7 | const app = express(); 8 | let data = {}; 9 | app.get('/:className', (req, res) => { 10 | res.send('ok'); 11 | let className = req.params.className; 12 | if (typeof className === 'string' && className.length < 128) { 13 | data[className] = data[className] || 0; 14 | data[className]++; 15 | } 16 | }); 17 | app.listen(PORT, () => console.log(`LanceInfo listening on ${PORT}`)); 18 | 19 | let syncData = () => { 20 | const d = new Date(); 21 | fs.writeFile(`lanceInfo-${d.getFullYear()}-${d.getMonth() + 1}.json`, JSON.stringify(data), () => {}); 22 | setTimeout(syncData, 60 * 1000); 23 | }; 24 | 25 | syncData(); 26 | -------------------------------------------------------------------------------- /utils/npm-install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | source /home/ec2-user/.bash_profile 3 | mkdir -p /var/games/lance 4 | cd /var/games/lance 5 | npm install 6 | npm run docs 7 | 8 | 9 | # upload static files to s3 10 | cd /var/games/lance/docs_out && aws s3 sync --acl public-read --delete . s3://docs.lance.gg 11 | 12 | # invalidate CDN 13 | aws configure set preview.cloudfront true && aws cloudfront create-invalidation --distribution-id EZZ6SM0QWM7CC --paths "/*" 14 | -------------------------------------------------------------------------------- /utils/showMovement.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const process = require('process'); 3 | 4 | // 5 | // this little utility shows the deltas between positions, quaternions, and time on each step 6 | // 7 | let FILENAME = 'server.trace'; 8 | let states = {}; 9 | 10 | if (process.argv.length === 3) FILENAME = process.argv[2]; 11 | let fin = fs.readFileSync(FILENAME); 12 | let lines = fin.toString().split('\n'); 13 | for (let l of lines) { 14 | if (l.indexOf('after step') < 0) continue; 15 | 16 | // position match: Pos[0, -15.4, 0] 17 | let p = l.match(/Pos\[([0-9.-]*), ([0-9.-]*), ([0-9.-]*)\]/); 18 | 19 | // quaternion match: Dir[0.34, [0, 1, 0]] 20 | let q = l.match(/Dir\[([0-9.-]*),[([0-9.-]*), ([0-9.-]*), ([0-9.-]*)\]\]/); 21 | 22 | // match: 2017-06-01T14:25:24.197Z 23 | let ts = l.match(/^\[([0-9\-T:.Z]*)\]/); 24 | let t = new Date(ts[1]); 25 | 26 | let step = l.match(/([0-9]*>)/); 27 | let parts = l.split(' '); 28 | let objname = parts[4]; 29 | let old = states[objname]; 30 | if (old) { 31 | let deltaP = `(${Number(p[1]) - Number(old.p[1])},${Number(p[2]) - Number(old.p[2])},${Number(p[3]) - Number(old.p[3])})`; 32 | let deltaQ = `(${Number(q[1]) - Number(old.q[1])},${Number(q[2]) - Number(old.q[2])},${Number(q[3]) - Number(old.q[3])},${Number(q[4]) - Number(old.q[4])})`; 33 | console.log(`step ${step[1]} dt=${t - old.t} object ${objname} moved ${deltaP} rotated ${deltaQ}`); 34 | } 35 | states[objname] = { p, q, t }; 36 | } 37 | --------------------------------------------------------------------------------