├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── config.yml └── workflows │ └── main.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── LICENSE ├── README.md ├── bundle └── snapshot-interpolation.js ├── example ├── client │ └── index.js ├── common.js ├── package.json └── server │ └── index.js ├── package-lock.json ├── package.json ├── readme ├── logo.png └── logo.svg ├── src ├── bundle.ts ├── index.ts ├── lerp.ts ├── slerp.ts ├── snapshot-interpolation.ts ├── types.ts └── vault.ts ├── test ├── compression.test.js ├── deep.test.js ├── lerp.test.js ├── slerp.test.js ├── snapshot.test.js └── vault.test.js ├── tsconfig.json ├── webpack.bundle.js ├── webpack.bundle.tmp.js └── webpack.config.js /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: yandeu 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **Have a question?** 13 | Join the [discussions](https://github.com/geckosio/geckos.io/discussions) instead. 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Ask a question 4 | url: https://github.com/geckosio/geckos.io/discussions 5 | about: Ask questions and discuss with other community members 6 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | timeout-minutes: 5 9 | 10 | strategy: 11 | matrix: 12 | node-version: [18.x, 20.x, 22.x] 13 | 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v2 17 | 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v2 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | 23 | - name: Install and Build 24 | run: | 25 | npm install 26 | npm run build 27 | 28 | - name: Test 29 | run: npm test 30 | 31 | - name: Webpack Bundle 32 | run: npm run bundle:tmp 33 | 34 | - name: Upload code coverage 35 | uses: codecov/codecov-action@v2 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /lib 3 | /node_modules 4 | /bundle/snapshot-interpolation.tmp.js 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | !/.npmrc 4 | !/README.md 5 | !/bundle 6 | !/lib 7 | !/package.json 8 | !/readme -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | "@yandeu/prettier-config" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2021, Yannick Deubel (https://github.com/yandeu); Project Url: https://github.com/geckosio/snapshot-interpolation 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | logo 4 | 5 | # Snapshot Interpolation 6 | 7 | ## A Snapshot Interpolation library for Real-Time Multiplayer Games 8 | 9 | #### Easily add Snapshot Interpolation (also called Entity Interpolation or Buffer Interpolation) to your Games. 10 | 11 | [![NPM version](https://img.shields.io/npm/v/@geckos.io/snapshot-interpolation.svg?style=flat-square)](https://www.npmjs.com/package/@geckos.io/snapshot-interpolation) 12 | [![Github Workflow](https://img.shields.io/github/workflow/status/geckosio/snapshot-interpolation/CI/master?label=github%20build&logo=github&style=flat-square)](https://github.com/geckosio/snapshot-interpolation/actions?query=workflow%3ACI) 13 | [![GitHub last commit](https://img.shields.io/github/last-commit/geckosio/snapshot-interpolation?style=flat-square)](https://github.com/geckosio/snapshot-interpolation/commits/master) 14 | [![Downloads](https://img.shields.io/npm/dm/@geckos.io/snapshot-interpolation.svg?style=flat-square)](https://www.npmjs.com/package/@geckos.io/snapshot-interpolation) 15 | [![Codecov](https://img.shields.io/codecov/c/github/geckosio/snapshot-interpolation?logo=codecov&style=flat-square)](https://codecov.io/gh/geckosio/snapshot-interpolation) 16 | [![build with TypeScript](https://img.shields.io/badge/built%20with-TypeScript-blue?style=flat-square)](https://www.typescriptlang.org/) 17 | 18 |
19 | 20 | --- 21 | 22 | ## About 23 | 24 | The Interpolation Buffer is by default 3 server frames long (Interpolation between 4 Snapshots). 25 | So if the **latency is 50ms** and one **server frame is 50ms**, the Interpolation Buffer would be 200ms long. 26 | 27 | If you are interested to learn a bit more about Snapshot Interpolation, watch [this video](https://youtu.be/Z9X4lysFr64?t=800). 28 | 29 | ## Features 30 | 31 | - Easily add **Client-Side Prediction** and **Server Reconciliation**. 32 | - Easily add **Lag Compensation**. 33 | - Easily **compress/encode** your snapshots before sending/receiving. 34 | 35 | ## Introduction 36 | 37 | Take a look at these YouTube videos: 38 | 39 | - [Short Version](https://youtu.be/-9ix6JxpqGo) 40 | - [Long Version](https://youtu.be/ciNR4t-5-WI) 41 | 42 | ## Game Example 43 | 44 | The [github repository](https://github.com/geckosio/snapshot-interpolation) contains a nice example. Take a look! 45 | 46 | ```bash 47 | # clone the repo 48 | $ git clone https://github.com/geckosio/snapshot-interpolation.git 49 | 50 | # cd into it 51 | $ cd snapshot-interpolation 52 | 53 | # install all dependencies 54 | $ npm install 55 | 56 | # start the example 57 | $ npm start 58 | 59 | # play in your browser 60 | http://localhost:8080/ 61 | ``` 62 | 63 | ## Install 64 | 65 | Install from npm. 66 | 67 | ```console 68 | npm install @geckos.io/snapshot-interpolation 69 | ``` 70 | 71 | Or use the bundled version. 72 | 73 | ```html 74 | 75 | 78 | ``` 79 | 80 | ## How to use 81 | 82 | ### server.js 83 | 84 | ```js 85 | // import @geckos.io/snapshot-interpolation 86 | import { SnapshotInterpolation } from '@geckos.io/snapshot-interpolation' 87 | 88 | // initialize the library 89 | const SI = new SnapshotInterpolation() 90 | 91 | // your server update loop 92 | update() { 93 | // create a snapshot of the current world 94 | const snapshot = SI.snapshot.create(worldState) 95 | 96 | // add the snapshot to the vault in case you want to access it later (optional) 97 | SI.vault.add(snapshot) 98 | 99 | // send the snapshot to the client (using geckos.io or any other library) 100 | this.emit('update', snapshot) 101 | } 102 | ``` 103 | 104 | ### client.js 105 | 106 | ```js 107 | // import @geckos.io/snapshot-interpolation 108 | import { SnapshotInterpolation } from '@geckos.io/snapshot-interpolation' 109 | 110 | // initialize the library (add your server's fps) 111 | const SI = new SnapshotInterpolation(serverFPS) 112 | 113 | // when receiving the snapshot on the client 114 | this.on('update', (snapshot) => { 115 | // read the snapshot 116 | SI.snapshot.add(snapshot) 117 | } 118 | 119 | // your client update loop 120 | update() { 121 | // calculate the interpolation for the parameters x and y and return the snapshot 122 | const snapshot = SI.calcInterpolation('x y') // [deep: string] as optional second parameter 123 | 124 | // access your state 125 | const { state } = snapshot 126 | 127 | // apply the interpolated values to you game objects 128 | const { id, x, y } = state[0] 129 | if (hero.id === id) { 130 | hero.x = x 131 | hero.y = y 132 | } 133 | } 134 | ``` 135 | 136 | ## World State 137 | 138 | The World State has to be an Array with non nested Objects (except for Quaternions). You can name you keys as you want. For degree, radian or quaternion values add `key(deg)`, `key(rad)` or `key(quat)`. 139 | 140 | ### Linear Interpolation 141 | 142 | ```js 143 | // the worldState on the server 144 | const worldState = [ 145 | { id: 'heroBlue', x: 23, y: 14, z: 47 }, 146 | { id: 'heroRed', x: 23, y: 14, z: 47 }, 147 | { id: 'heroGreen', x: 23, y: 14, z: 47 } 148 | ] 149 | 150 | // calc interpolation on the client 151 | SI.calcInterpolation('x y z') 152 | ``` 153 | 154 | ### Degrees, Radians and Quaternions 155 | 156 | ```js 157 | // the worldState on the server 158 | const worldState = [ 159 | { 160 | id: 'myHero', 161 | direction: Math.PI / 2, 162 | rotationInDeg: 90, 163 | q: { x: 0, y: 0.707, z: 0, w: 0.707 } 164 | } 165 | ] 166 | 167 | // calc interpolation on the client 168 | SI.calcInterpolation('direction(rad) rotationInDeg(deg) q(quat)') 169 | ``` 170 | 171 | ## Multiple States 172 | 173 | ```js 174 | // the players state 175 | const playersState = [ 176 | { id: 0, x: 65, y: -17 }, 177 | { id: 1, x: 32, y: 9 } 178 | ] 179 | 180 | // the towers state 181 | const towersState = [ 182 | { id: 0, health: 100, type: 'blizzardCannon' }, 183 | { id: 1, health: 89, type: 'flameThrower' } 184 | ] 185 | 186 | const worldState = { 187 | players: playersState, 188 | towers: towersState 189 | } 190 | 191 | // create snapshot 192 | SI.snapshot.create(worldState) 193 | 194 | // we only want to interpolate x and y on the players object 195 | SI.calcInterpolation('x y', 'players') 196 | ``` 197 | 198 | ## Vault 199 | 200 | The Vault holds and secures all your Snapshots. Each SnapshotInterpolation instance holds one Vault, but you can easily create more if you need: 201 | 202 | ```js 203 | import { Vault } from '@geckos.io/snapshot-interpolation' 204 | 205 | const customVault = new Vault() 206 | ``` 207 | 208 | ## Compression 209 | 210 | You can use the package [@geckos.io/typed-array-buffer-schema](https://www.npmjs.com/package/@geckos.io/typed-array-buffer-schema) to compress your snapshots before sending them to the client. 211 | 212 | ```js 213 | // schema.js 214 | const playerSchema = BufferSchema.schema('player', { 215 | id: uint8, 216 | x: { type: int16, digits: 1 }, 217 | y: { type: int16, digits: 1 } 218 | }) 219 | 220 | const snapshotSchema = BufferSchema.schema('snapshot', { 221 | id: { type: string8, length: 6 }, 222 | time: uint64, 223 | state: { players: [playerSchema] } 224 | }) 225 | 226 | export const snapshotModel = new Model(snapshotSchema) 227 | 228 | // server.js 229 | const snapshot = SI.snapshot.create({ 230 | players: [ 231 | { id: 0, x: 17.5, y: -1.1 }, 232 | { id: 1, x: 5.8, y: 18.9 } 233 | ] 234 | }) 235 | 236 | const buffer = snapshotModel.toBuffer(snapshot) 237 | send(buffer) 238 | 239 | // client.js 240 | receive(buffer => { 241 | snapshot = snapshotModel.fromBuffer(buffer) 242 | SI.addSnapshot(snapshot) 243 | }) 244 | ``` 245 | 246 | ## API 247 | 248 | This looks very TypeScriptisch, but you can of course use it in JavaScript as well. 249 | 250 | ```ts 251 | // import 252 | import { SnapshotInterpolation, Vault, Types } from '@geckos.io/snapshot-interpolation' 253 | 254 | // types 255 | type Value = number | string | Quat | undefined 256 | type ID = string 257 | type Time = number 258 | type State = Entity[] 259 | type Quat = { x: number; y: number; z: number; w: number } 260 | 261 | // interfaces 262 | export interface Entity { 263 | id: string 264 | [key: string]: Value 265 | } 266 | export interface Snapshot { 267 | id: ID 268 | time: Time 269 | state: State | { [key: string]: State } 270 | } 271 | export interface InterpolatedSnapshot { 272 | state: State 273 | percentage: number 274 | older: ID 275 | newer: ID 276 | } 277 | 278 | // static methods 279 | /** Create a new Snapshot */ 280 | SnapshotInterpolation.CreateSnapshot(state: State): Snapshot 281 | 282 | /** Create a new ID */ 283 | SnapshotInterpolation.NewId(): string 284 | 285 | /** Get the current time in milliseconds. */ 286 | SnapshotInterpolation.Now(): number 287 | 288 | // class SnapshotInterpolation 289 | const SI = new SnapshotInterpolation(serverFPS?: number) 290 | 291 | /** Access the vault. */ 292 | SI.vault: Vault 293 | 294 | /** Get the Interpolation Buffer time in milliseconds. */ 295 | SI.interpolationBuffer.get(): number 296 | 297 | /** Set the Interpolation Buffer time in milliseconds. */ 298 | SI.interpolationBuffer.set(milliseconds: number): void 299 | 300 | /** Create the snapshot on the server. */ 301 | SI.snapshot.create(state: State): Snapshot 302 | 303 | /** Add the snapshot you received from the server to automatically calculate the interpolation with calcInterpolation() */ 304 | SI.snapshot.add(snapshot: Snapshot): void 305 | 306 | /** Interpolate between two snapshots give the percentage or time. */ 307 | SI.interpolate(snapshotA: Snapshot, snapshotB: Snapshot, timeOrPercentage: number, parameters: string, deep: string): InterpolatedSnapshot 308 | 309 | /** Get the calculated interpolation on the client. */ 310 | SI.calcInterpolation(parameters: string, deep: string): InterpolatedSnapshot | undefined 311 | 312 | // class Vault 313 | const vault = new Vault() 314 | 315 | /** Get a Snapshot by its ID. */ 316 | vault.getById(id: ID): Snapshot 317 | 318 | /** Get the latest snapshot */ 319 | vault.get(): Snapshot | undefined; 320 | 321 | /** Get the two snapshots around a specific time */ 322 | vault.get(time: number): { older: Snapshot; newer: Snapshot; } | undefined 323 | 324 | /** Get the closest snapshot to e specific time */ 325 | vault.get(time: number, closest: boolean): Snapshot | undefined 326 | 327 | /** Add a snapshot to the vault. */ 328 | vault.add(snapshot: Snapshot): void 329 | 330 | /** Get the current capacity (size) of the vault. */ 331 | vault.size(): number 332 | 333 | /** Set the max capacity (size) of the vault. */ 334 | vault.setMaxSize(size: number): void 335 | 336 | /** Get the max capacity (size) of the vault. */ 337 | vault.getMaxSize(): number 338 | 339 | ``` 340 | -------------------------------------------------------------------------------- /bundle/snapshot-interpolation.js: -------------------------------------------------------------------------------- 1 | var Snap;(()=>{"use strict";var t={156:function(t,e,r){ 2 | /** 3 | * @author Yannick Deubel (https://github.com/yandeu) 4 | * @copyright Copyright (c) 2021 Yannick Deubel; Project Url: https://github.com/geckosio/snapshot-interpolation 5 | * @license {@link https://github.com/geckosio/snapshot-interpolation/blob/master/LICENSE|GNU GPLv3} 6 | */ 7 | var i=this&&this.__createBinding||(Object.create?function(t,e,r,i){void 0===i&&(i=r);var a=Object.getOwnPropertyDescriptor(e,r);a&&!("get"in a?!e.__esModule:a.writable||a.configurable)||(a={enumerable:!0,get:function(){return e[r]}}),Object.defineProperty(t,i,a)}:function(t,e,r,i){void 0===i&&(i=r),t[i]=e[r]}),a=this&&this.__setModuleDefault||(Object.create?function(t,e){Object.defineProperty(t,"default",{enumerable:!0,value:e})}:function(t,e){t.default=e}),n=this&&this.__importStar||function(t){if(t&&t.__esModule)return t;var e={};if(null!=t)for(var r in t)"default"!==r&&Object.prototype.hasOwnProperty.call(t,r)&&i(e,t,r);return a(e,t),e};Object.defineProperty(e,"__esModule",{value:!0}),e.Types=e.Vault=e.SnapshotInterpolation=void 0;var o=r(357);Object.defineProperty(e,"SnapshotInterpolation",{enumerable:!0,get:function(){return o.SnapshotInterpolation}});var s=r(552);Object.defineProperty(e,"Vault",{enumerable:!0,get:function(){return s.Vault}}),e.Types=n(r(613))},164:(t,e)=>{ 8 | /** 9 | * @author three.js authors 10 | * @copyright Copyright © 2010-2021 three.js authors 11 | * @license {@link https://github.com/mrdoob/three.js/blob/dev/LICENSE|MIT} 12 | * @description Copied and modified from: https://github.com/mrdoob/three.js/blob/464efc85ecfda5c03d786d15d8f8eff20d70f256/src/math/Quaternion.js 13 | */ 14 | Object.defineProperty(e,"__esModule",{value:!0}),e.quatSlerp=void 0;e.quatSlerp=(t,e,r)=>{if(0===r)return t;if(1===r)return e;let i=t.x,a=t.y,n=t.z,o=t.w;const s=e.x,l=e.y,u=e.z,f=e.w;if(o!==f||i!==s||a!==l||n!==u){let t=1-r;const e=i*s+a*l+n*u+o*f,p=e>=0?1:-1,d=1-e*e;if(d>.001){const i=Math.sqrt(d),a=Math.atan2(i,e*p);t=Math.sin(t*a)/i,r=Math.sin(r*a)/i}const h=r*p;if(i=i*t+s*h,a=a*t+l*h,n=n*t+u*h,o=o*t+f*h,t===1-r){const t=1/Math.sqrt(i*i+a*a+n*n+o*o);i*=t,a*=t,n*=t,o*=t}}return{x:i,y:a,z:n,w:o}}},357:(t,e,r)=>{Object.defineProperty(e,"__esModule",{value:!0}),e.SnapshotInterpolation=void 0;const i=r(552),a=r(671),n=r(164);class o{constructor(t,e={}){this.vault=new i.Vault,this._interpolationBuffer=100,this._timeOffset=-1,this.serverTime=0,t&&(this._interpolationBuffer=1e3/t*3),this.config={autoCorrectTimeOffset:!0,...e}}get interpolationBuffer(){return{get:()=>this._interpolationBuffer,set:t=>{this._interpolationBuffer=t}}}static Now(){return Date.now()}get timeOffset(){return this._timeOffset}static NewId(){return Math.random().toString(36).substr(2,6)}get snapshot(){return{create:t=>o.CreateSnapshot(t),add:t=>this.addSnapshot(t)}}static CreateSnapshot(t){const e=t=>{if(!Array.isArray(t))throw new Error("You have to pass an Array to createSnapshot()");if(t.filter((t=>"string"!=typeof t.id&&"number"!=typeof t.id)).length>0)throw new Error("Each Entity needs to have a id")};return Array.isArray(t)?e(t):Object.keys(t).forEach((r=>{e(t[r])})),{id:o.NewId(),time:o.Now(),state:t}}addSnapshot(t){var e;const r=o.Now(),i=t.time;if(-1===this._timeOffset&&(this._timeOffset=r-i),!0===(null===(e=this.config)||void 0===e?void 0:e.autoCorrectTimeOffset)){const t=r-i;Math.abs(this._timeOffset-t)>50&&(this._timeOffset=t)}this.vault.add(t)}interpolate(t,e,r,i,a=""){return this._interpolate(t,e,r,i,a)}_interpolate(t,e,r,i,o){const s=[t,e].sort(((t,e)=>e.time-t.time)),l=i.trim().replace(/\W+/," ").split(" "),u=s[0],f=s[1],p=u.time,d=f.time,h=r<=1?r:(r-d)/(p-d);this.serverTime=(0,a.lerp)(d,p,h);if(!Array.isArray(u.state)&&""===o)throw new Error('You forgot to add the "deep" parameter.');if(Array.isArray(u.state)&&""!==o)throw new Error('No "deep" needed it state is an array.');const c=Array.isArray(u.state)?u.state:u.state[o],v=Array.isArray(f.state)?f.state:f.state[o];let _=JSON.parse(JSON.stringify({...u,state:c}));c.forEach(((t,e)=>{const r=t.id,i=v.find((t=>t.id===r));i&&l.forEach((r=>{const o=r.match(/\w\(([\w]+)\)/),s=o?null==o?void 0:o[1]:"linear";o&&(r=null==o?void 0:o[0].replace(/\([\S]+$/gm,""));const l=null==t?void 0:t[r],u=((t,e,r,i)=>{if(void 0!==e&&void 0!==r){if("string"==typeof e||"string"==typeof r)throw new Error("Can't interpolate string!");if("number"==typeof e&&"number"==typeof r){if("linear"===t)return(0,a.lerp)(e,r,i);if("deg"===t)return(0,a.degreeLerp)(e,r,i);if("rad"===t)return(0,a.radianLerp)(e,r,i)}if("object"==typeof e&&"object"==typeof r&&"quat"===t)return(0,n.quatSlerp)(e,r,i);throw new Error(`No lerp method "${t}" found!`)}})(s,null==i?void 0:i[r],l,h);Array.isArray(_.state)&&(_.state[e][r]=u)}))}));return{state:_.state,percentage:h,newer:u.id,older:f.id}}calcInterpolation(t,e=""){const r=o.Now()-this._timeOffset-this._interpolationBuffer,i=this.vault.get(r);if(!i)return;const{older:a,newer:n}=i;return a&&n?this._interpolate(n,a,r,t,e):void 0}}e.SnapshotInterpolation=o},552:(t,e)=>{Object.defineProperty(e,"__esModule",{value:!0}),e.Vault=void 0;e.Vault=class{constructor(){this._vault=[],this._vaultSize=120}getById(t){var e;return null===(e=this._vault.filter((e=>e.id===t)))||void 0===e?void 0:e[0]}clear(){this._vault=[]}get(t,e){var r;const i=this._vault.sort(((t,e)=>e.time-t.time));if(void 0===t)return i[0];for(let a=0;athis._vaultSize-1&&this._vault.sort(((t,e)=>t.time-e.time)).shift(),this._vault.push(t)}get size(){return this._vault.length}setMaxSize(t){this._vaultSize=t}getMaxSize(){return this._vaultSize}}},613:(t,e)=>{Object.defineProperty(e,"__esModule",{value:!0})},671:(t,e)=>{Object.defineProperty(e,"__esModule",{value:!0}),e.radianLerp=e.degreeLerp=e.lerp=void 0;const r=3.14159265359,i=6.28318530718;e.lerp=(t,e,r)=>t+(e-t)*r;e.degreeLerp=(t,r,i)=>{let a,n=r-t;return n<-180?(r+=360,a=(0,e.lerp)(t,r,i),a>=360&&(a-=360)):n>180?(r-=360,a=(0,e.lerp)(t,r,i),a<0&&(a+=360)):a=(0,e.lerp)(t,r,i),a};e.radianLerp=(t,a,n)=>{let o,s=a-t;return s<-r?(a+=i,o=(0,e.lerp)(t,a,n),o>=i&&(o-=i)):s>r?(a-=i,o=(0,e.lerp)(t,a,n),o<0&&(o+=i)):o=(0,e.lerp)(t,a,n),o}}},e={};function r(i){var a=e[i];if(void 0!==a)return a.exports;var n=e[i]={exports:{}};return t[i].call(n.exports,n,n.exports,r),n.exports}var i={};(()=>{var t=i;const e=r(156),a=r(156);t.default={SnapshotInterpolation:e.SnapshotInterpolation,Vault:a.Vault}})(),Snap=i.default})(); -------------------------------------------------------------------------------- /example/client/index.js: -------------------------------------------------------------------------------- 1 | import { SnapshotInterpolation, Vault } from '../../lib/index.js' 2 | import geckos from '@geckos.io/client' 3 | import { addLatencyAndPackagesLoss, collisionDetection } from '../common.js' 4 | 5 | const channel = geckos() 6 | const SI = new SnapshotInterpolation(15) // the server's fps is 15 7 | const playerVault = new Vault() 8 | const players = new Map() 9 | 10 | const body = document.body 11 | body.style.padding = 0 12 | body.style.margin = 0 13 | body.style.overflow = 'hidden' 14 | body.style.background = '#21222c' 15 | 16 | const canvas = document.createElement('canvas') 17 | const width = 1280 18 | const height = 720 19 | canvas.width = width 20 | canvas.height = height 21 | let worldScale = 1 22 | 23 | const resize = () => { 24 | const w = window.innerWidth 25 | const h = window.innerHeight 26 | const scaleX = w / canvas.width 27 | const scaleY = h / canvas.height 28 | const scale = (worldScale = Math.min(scaleX, scaleY)) 29 | 30 | canvas.style.width = `${width * scale}px` 31 | canvas.style.height = `${height * scale}px` 32 | 33 | canvas.style.margin = ` ${h / 2 - (height * scale) / 2}px 0px 0px ${w / 2 - (width * scale) / 2}px` 34 | } 35 | resize() 36 | window.addEventListener('resize', () => resize()) 37 | 38 | body.appendChild(canvas) 39 | const ctx = canvas.getContext('2d') 40 | 41 | // add bots button 42 | window.isBot = false 43 | const button = document.createElement('button') 44 | button.innerHTML = 'Make Bot' 45 | button.style.position = 'absolute' 46 | button.style.top = '50px' 47 | button.style.left = 'calc(50% - 50px)' 48 | button.style.fontSize = '18px' 49 | button.style.padding = '8px 12px' 50 | button.addEventListener('click', () => { 51 | window.isBot = !window.isBot 52 | button.innerHTML = window.isBot ? 'Stop Bot' : 'Make Bot' 53 | keys = { left: false, up: false, right: false, down: false } 54 | }) 55 | body.appendChild(button) 56 | 57 | let connected = false 58 | 59 | let tick = 0 60 | 61 | let keys = { 62 | left: false, 63 | up: false, 64 | right: false, 65 | down: false 66 | } 67 | 68 | channel.onConnect(error => { 69 | if (error) { 70 | console.error(error.message) 71 | return 72 | } else { 73 | console.log('You are connected!') 74 | } 75 | 76 | connected = true 77 | 78 | channel.on('update', snapshot => { 79 | addLatencyAndPackagesLoss(() => { 80 | SI.snapshot.add(snapshot) 81 | }) 82 | }) 83 | 84 | channel.on('hit', entity => { 85 | addLatencyAndPackagesLoss(() => { 86 | const player = players.get(entity.id) 87 | if (player) { 88 | player.color = '#50fa7b' 89 | setTimeout(() => { 90 | player.color = '#87e9f0' 91 | }, 500) 92 | } 93 | console.log('You just hit ', entity.id) 94 | }, false) 95 | }) 96 | 97 | channel.on('removePlayer', id => { 98 | if (players.has(id)) { 99 | setTimeout(() => { 100 | players.delete(id) 101 | }, 1000) 102 | } 103 | }) 104 | }) 105 | 106 | const render = () => { 107 | ctx.clearRect(0, 0, canvas.width, canvas.height) 108 | 109 | ctx.fillStyle = '#6070a1' 110 | ctx.fillRect(0, 0, canvas.width, canvas.height) 111 | 112 | players.forEach(p => { 113 | ctx.beginPath() 114 | ctx.fillStyle = p.color || '#87e9f0' 115 | ctx.rect(p.x, p.y, 40, 60) 116 | ctx.fill() 117 | }) 118 | } 119 | 120 | const serverReconciliation = () => { 121 | const { left, up, right, down } = keys 122 | const player = players.get(channel.id) 123 | 124 | if (player) { 125 | // get the latest snapshot from the server 126 | const serverSnapshot = SI.vault.get() 127 | // get the closest player snapshot that matches the server snapshot time 128 | const playerSnapshot = playerVault.get(serverSnapshot.time, true) 129 | 130 | if (serverSnapshot && playerSnapshot) { 131 | // get the current player position on the server 132 | const serverPos = serverSnapshot.state.filter(s => s.id === channel.id)[0] 133 | 134 | // calculate the offset between server and client 135 | const offsetX = playerSnapshot.state[0].x - serverPos.x 136 | const offsetY = playerSnapshot.state[0].y - serverPos.y 137 | 138 | // check if the player is currently on the move 139 | const isMoving = left || up || right || down 140 | 141 | // we correct the position faster if the player moves 142 | const correction = isMoving ? 60 : 180 143 | 144 | // apply a step by step correction of the player's position 145 | player.x -= offsetX / correction 146 | player.y -= offsetY / correction 147 | } 148 | } 149 | } 150 | 151 | const clientPrediction = () => { 152 | const { left, up, right, down } = keys 153 | const speed = 3 154 | const player = players.get(channel.id) 155 | 156 | if (player) { 157 | if (left) player.x -= speed 158 | if (up) player.y -= speed 159 | if (right) player.x += speed 160 | if (down) player.y += speed 161 | playerVault.add(SI.snapshot.create([{ id: channel.id, x: player.x, y: player.y }])) 162 | } 163 | } 164 | 165 | const loop = () => { 166 | tick++ 167 | if (connected) { 168 | if (window.isBot) { 169 | const player = players.get(channel.id) 170 | if (typeof player.direction === 'undefined') player.direction = 'right' 171 | if (player.x + 40 > canvas.width) player.direction = 'left' 172 | else if (player.x < 0) player.direction = 'right' 173 | 174 | if (player.direction === 'right') keys = { left: false, up: false, right: true, down: false } 175 | if (player.direction === 'left') keys = { left: true, up: false, right: false, down: false } 176 | } 177 | 178 | const update = [keys.left, keys.up, keys.right, keys.down] 179 | channel.emit('move', update) 180 | } 181 | 182 | clientPrediction() 183 | serverReconciliation() 184 | 185 | const snapshot = SI.calcInterpolation('x y') // interpolated 186 | // const snapshot = SI.vault.get() // latest 187 | if (snapshot) { 188 | const { state } = snapshot 189 | state.forEach(s => { 190 | const { id, x, y } = s 191 | // update player 192 | if (players.has(id)) { 193 | // do not update our own player (if we use clientPrediction and serverReconciliation) 194 | if (id === channel.id) return 195 | 196 | const player = players.get(id) 197 | player.x = x 198 | player.y = y 199 | } else { 200 | // add new player 201 | players.set(id, { id, x, y }) 202 | } 203 | }) 204 | } 205 | 206 | render() 207 | 208 | requestAnimationFrame(loop) 209 | } 210 | 211 | loop() 212 | 213 | canvas.addEventListener('pointerdown', e => { 214 | let { clientX, clientY } = e 215 | const rect = canvas.getBoundingClientRect() 216 | 217 | clientX -= rect.left 218 | clientY -= rect.top 219 | clientX /= worldScale 220 | clientY /= worldScale 221 | 222 | let hit = false 223 | players.forEach(entity => { 224 | if ( 225 | collisionDetection( 226 | { x: entity.x, y: entity.y, width: 40, height: 60 }, 227 | // make the pointer 10px by 10px 228 | { x: clientX - 5, y: clientY - 5, width: 10, height: 10 } 229 | ) 230 | ) { 231 | entity.color = '#ff79c6' 232 | hit = true 233 | } 234 | }) 235 | 236 | if (connected && hit) channel.emit('shoot', { x: clientX, y: clientY, time: SI.serverTime }, { reliable: true }) 237 | }) 238 | 239 | document.addEventListener('keydown', e => { 240 | const { keyCode } = e 241 | switch (keyCode) { 242 | case 37: 243 | keys.left = true 244 | break 245 | case 38: 246 | keys.up = true 247 | break 248 | case 39: 249 | keys.right = true 250 | break 251 | case 40: 252 | keys.down = true 253 | break 254 | } 255 | }) 256 | 257 | document.addEventListener('keyup', e => { 258 | const { keyCode } = e 259 | switch (keyCode) { 260 | case 37: 261 | keys.left = false 262 | break 263 | case 38: 264 | keys.up = false 265 | break 266 | case 39: 267 | keys.right = false 268 | break 269 | case 40: 270 | keys.down = false 271 | break 272 | } 273 | }) 274 | -------------------------------------------------------------------------------- /example/common.js: -------------------------------------------------------------------------------- 1 | export const addLatencyAndPackagesLoss = (fnc, loss = true) => { 2 | if (loss && Math.random() > 0.9) return // 10% package loss 3 | setTimeout(() => fnc(), 100 + Math.random() * 50) // random latency between 100 and 150 4 | } 5 | 6 | // https://developer.mozilla.org/en-US/docs/Games/Techniques/2D_collision_detection 7 | export const collisionDetection = (rect1, rect2) => { 8 | if ( 9 | rect1.x < rect2.x + rect2.width && 10 | rect1.x + rect1.width > rect2.x && 11 | rect1.y < rect2.y + rect2.height && 12 | rect1.y + rect1.height > rect2.y 13 | ) { 14 | return true 15 | } 16 | return false 17 | } 18 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module" 3 | } -------------------------------------------------------------------------------- /example/server/index.js: -------------------------------------------------------------------------------- 1 | import { SnapshotInterpolation } from '../../lib/index.js' 2 | import { addLatencyAndPackagesLoss, collisionDetection } from '../common.js' 3 | 4 | const geckos_module = await import('@geckos.io/server') 5 | const { geckos } = geckos_module 6 | 7 | const io = geckos() 8 | const SI = new SnapshotInterpolation() 9 | const players = new Map() 10 | let tick = 0 11 | 12 | io.listen() 13 | 14 | io.onConnection(channel => { 15 | players.set(channel.id, { 16 | x: Math.random() * 500, 17 | y: Math.random() * 500 18 | }) 19 | 20 | channel.onDisconnect(() => { 21 | io.emit('removePlayer', channel.id) 22 | if (players.has(channel.id)) { 23 | players.delete(channel.id) 24 | } 25 | }) 26 | 27 | channel.on('move', data => { 28 | addLatencyAndPackagesLoss(() => { 29 | const player = players.get(channel.id) 30 | if (player) { 31 | player.vx = data[2] - data[0] 32 | player.vy = data[3] - data[1] 33 | } 34 | }) 35 | }) 36 | 37 | channel.on('shoot', data => { 38 | addLatencyAndPackagesLoss(() => { 39 | const { x, y, time } = data 40 | 41 | // get the two closest snapshot to the date 42 | const shots = SI.vault.get(time) 43 | if (!shots) return 44 | 45 | // interpolate between both snapshots 46 | const shot = SI.interpolate(shots.older, shots.newer, time, 'x y') 47 | if (!shot) return 48 | 49 | // check for a collision 50 | shot.state.forEach(entity => { 51 | if ( 52 | collisionDetection( 53 | { x: entity.x, y: entity.y, width: 40, height: 60 }, 54 | // make the pointer 10px by 10px 55 | { x: x - 5, y: y - 5, width: 10, height: 10 } 56 | ) 57 | ) { 58 | channel.emit('hit', entity, { reliable: true }) 59 | } 60 | }) 61 | }, false) 62 | }) 63 | }) 64 | 65 | const loop = () => { 66 | tick++ 67 | 68 | // update world (physics etc.) 69 | const speed = 3 70 | players.forEach(player => { 71 | if (player.vx) player.x += player.vx * speed 72 | if (player.vy) player.y += player.vy * speed 73 | }) 74 | 75 | // send state on every 4th frame 76 | if (tick % 4 === 0) { 77 | const worldState = [] 78 | players.forEach((player, key) => { 79 | worldState.push({ 80 | id: key, 81 | x: parseFloat(player.x.toFixed(2)), 82 | y: parseFloat(player.y.toFixed(2)) 83 | }) 84 | }) 85 | 86 | const snapshot = SI.snapshot.create(worldState) 87 | SI.vault.add(snapshot) 88 | io.emit('update', snapshot) 89 | } 90 | } 91 | 92 | // server calculates position at 60 fps 93 | // but sends a snapshot at 15 fps (to save bandwidth) 94 | setInterval(loop, 1000 / 60) 95 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@geckos.io/snapshot-interpolation", 3 | "version": "1.1.1", 4 | "description": "A Snapshot Interpolation library for Real-Time Multiplayer Games", 5 | "main": "./lib/index.js", 6 | "types": "./lib/index.d.ts", 7 | "type": "commonjs", 8 | "scripts": { 9 | "test:ci": "npm i && npm run build && npm test && npm run bundle:tmp", 10 | "start": "npm run dev", 11 | "dev": "npm-run-all --parallel tsc:watch example:*", 12 | "example:server": "nodemon example/server/index.js", 13 | "example:client": "webpack serve", 14 | "tsc:watch": "tsc --watch", 15 | "test": "jest --collectCoverage", 16 | "build": "rimraf lib && tsc", 17 | "bundle": "webpack --config webpack.bundle.js", 18 | "bundle:tmp": "webpack --config webpack.bundle.tmp.js", 19 | "preReleaseHook": "prepublishOnly", 20 | "prepublishOnly": "npm i && npm run build && npm run bundle", 21 | "format": "prettier --write \"src/**/!(*.d).ts\" && prettier --write \"example/**/*.js\" && prettier --write \"test/**/*.js\"" 22 | }, 23 | "keywords": [ 24 | "multiplayer", 25 | "game", 26 | "network", 27 | "snapshot", 28 | "entity", 29 | "buffer", 30 | "interpolation", 31 | "client-side", 32 | "prediction", 33 | "server", 34 | "reconciliation", 35 | "lag", 36 | "compensation" 37 | ], 38 | "author": "Yannick Deubel (https://github.com/yandeu)", 39 | "license": "BSD-3-Clause", 40 | "repository": { 41 | "type": "git", 42 | "url": "git://github.com/geckosio/snapshot-interpolation.git" 43 | }, 44 | "bugs": { 45 | "url": "https://github.com/geckosio/snapshot-interpolation/issues" 46 | }, 47 | "homepage": "http://geckos.io", 48 | "devDependencies": { 49 | "@geckos.io/client": "^3.0.1", 50 | "@geckos.io/server": "^3.0.1", 51 | "@geckos.io/typed-array-buffer-schema": "^1.2.1", 52 | "@yandeu/prettier-config": "^0.0.3", 53 | "html-webpack-plugin": "^5.5.0", 54 | "jest": "^29.7.0", 55 | "nodemon": "^3.1.9", 56 | "npm-run-all": "^4.1.5", 57 | "rimraf": "^6.0.1", 58 | "ts-loader": "^9.2.6", 59 | "typescript": "^4.5.2", 60 | "webpack": "^5.64.4", 61 | "webpack-cli": "^6.0.1", 62 | "webpack-dev-server": "^5.2.0" 63 | }, 64 | "funding": { 65 | "url": "https://github.com/sponsors/yandeu" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /readme/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geckosio/snapshot-interpolation/a171cefb626085bfff5241cbd60c8099bdd742bf/readme/logo.png -------------------------------------------------------------------------------- /readme/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 39 | 41 | 42 | 44 | image/svg+xml 45 | 47 | 48 | 49 | 50 | 55 | 60 | 65 | 67 | 73 | 79 | 84 | 85 | 88 | 94 | 100 | 105 | 106 | 111 | 114 | 120 | 126 | 131 | 132 | 135 | 141 | 147 | 152 | 153 | 160 | 167 | 173 | 176 | 181 | 187 | 193 | 198 | 203 | 208 | 213 | 218 | 223 | 228 | 233 | 234 | 241 | 248 | 254 | 261 | 264 | 269 | 275 | 281 | 286 | 291 | 296 | 301 | 306 | 311 | 316 | 321 | 322 | 329 | 335 | 338 | 343 | 349 | 355 | 360 | 365 | 370 | 375 | 380 | 385 | 390 | 395 | 400 | 405 | 410 | 411 | 414 | 419 | 424 | 427 | 433 | 439 | 444 | 445 | 448 | 454 | 460 | 465 | 466 | 471 | 474 | 480 | 486 | 491 | 492 | 495 | 501 | 507 | 512 | 513 | 514 | 519 | 522 | 529 | 536 | 537 | 544 | 550 | 553 | 560 | 567 | 568 | 575 | 581 | 584 | 589 | 594 | 599 | 604 | 609 | 614 | 619 | 624 | 629 | 634 | 639 | t3 651 | t2 663 | t1 675 | t0 687 | 688 | 691 | 698 | 705 | 706 | 707 | 708 | 709 | -------------------------------------------------------------------------------- /src/bundle.ts: -------------------------------------------------------------------------------- 1 | import { SnapshotInterpolation } from './index' 2 | import { Vault } from './index' 3 | 4 | export default { SnapshotInterpolation, Vault } 5 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Yannick Deubel (https://github.com/yandeu) 3 | * @copyright Copyright (c) 2021 Yannick Deubel; Project Url: https://github.com/geckosio/snapshot-interpolation 4 | * @license {@link https://github.com/geckosio/snapshot-interpolation/blob/master/LICENSE|GNU GPLv3} 5 | */ 6 | 7 | export { SnapshotInterpolation } from './snapshot-interpolation' 8 | export { Vault } from './vault' 9 | export * as Types from './types' 10 | -------------------------------------------------------------------------------- /src/lerp.ts: -------------------------------------------------------------------------------- 1 | const PI = 3.14159265359 2 | const PI_TIMES_TWO = 6.28318530718 3 | 4 | export const lerp = (start: number, end: number, t: number) => { 5 | return start + (end - start) * t 6 | } 7 | 8 | // https://gist.github.com/itsmrpeck/be41d72e9d4c72d2236de687f6f53974 9 | export const degreeLerp = (start: number, end: number, t: number) => { 10 | let result 11 | let diff = end - start 12 | if (diff < -180) { 13 | // lerp upwards past 360 14 | end += 360 15 | result = lerp(start, end, t) 16 | if (result >= 360) { 17 | result -= 360 18 | } 19 | } else if (diff > 180) { 20 | // lerp downwards past 0 21 | end -= 360 22 | result = lerp(start, end, t) 23 | if (result < 0) { 24 | result += 360 25 | } 26 | } else { 27 | // straight lerp 28 | result = lerp(start, end, t) 29 | } 30 | 31 | return result 32 | } 33 | 34 | // https://gist.github.com/itsmrpeck/be41d72e9d4c72d2236de687f6f53974 35 | export const radianLerp = (start: number, end: number, t: number) => { 36 | let result 37 | let diff = end - start 38 | if (diff < -PI) { 39 | // lerp upwards past PI_TIMES_TWO 40 | end += PI_TIMES_TWO 41 | result = lerp(start, end, t) 42 | if (result >= PI_TIMES_TWO) { 43 | result -= PI_TIMES_TWO 44 | } 45 | } else if (diff > PI) { 46 | // lerp downwards past 0 47 | end -= PI_TIMES_TWO 48 | result = lerp(start, end, t) 49 | if (result < 0) { 50 | result += PI_TIMES_TWO 51 | } 52 | } else { 53 | // straight lerp 54 | result = lerp(start, end, t) 55 | } 56 | 57 | return result 58 | } 59 | -------------------------------------------------------------------------------- /src/slerp.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author three.js authors 3 | * @copyright Copyright © 2010-2021 three.js authors 4 | * @license {@link https://github.com/mrdoob/three.js/blob/dev/LICENSE|MIT} 5 | * @description Copied and modified from: https://github.com/mrdoob/three.js/blob/464efc85ecfda5c03d786d15d8f8eff20d70f256/src/math/Quaternion.js 6 | */ 7 | 8 | import { Quat } from './types' 9 | 10 | export const quatSlerp = (qa: Quat, qb: Quat, t: number) => { 11 | if (t === 0) return qa 12 | if (t === 1) return qb 13 | 14 | let x0 = qa.x 15 | let y0 = qa.y 16 | let z0 = qa.z 17 | let w0 = qa.w 18 | 19 | const x1 = qb.x 20 | const y1 = qb.y 21 | const z1 = qb.z 22 | const w1 = qb.w 23 | 24 | if (w0 !== w1 || x0 !== x1 || y0 !== y1 || z0 !== z1) { 25 | let s = 1 - t 26 | const cos = x0 * x1 + y0 * y1 + z0 * z1 + w0 * w1 27 | const dir = cos >= 0 ? 1 : -1 28 | const sqrSin = 1 - cos * cos 29 | 30 | // Skip the Slerp for tiny steps to avoid numeric problems: 31 | if (sqrSin > 0.001) { 32 | const sin = Math.sqrt(sqrSin) 33 | const len = Math.atan2(sin, cos * dir) 34 | 35 | s = Math.sin(s * len) / sin 36 | t = Math.sin(t * len) / sin 37 | } 38 | 39 | const tDir = t * dir 40 | 41 | x0 = x0 * s + x1 * tDir 42 | y0 = y0 * s + y1 * tDir 43 | z0 = z0 * s + z1 * tDir 44 | w0 = w0 * s + w1 * tDir 45 | 46 | // Normalize in case we just did a lerp: 47 | if (s === 1 - t) { 48 | const f = 1 / Math.sqrt(x0 * x0 + y0 * y0 + z0 * z0 + w0 * w0) 49 | x0 *= f 50 | y0 *= f 51 | z0 *= f 52 | w0 *= f 53 | } 54 | } 55 | 56 | return { x: x0, y: y0, z: z0, w: w0 } 57 | } 58 | -------------------------------------------------------------------------------- /src/snapshot-interpolation.ts: -------------------------------------------------------------------------------- 1 | import { Snapshot, InterpolatedSnapshot, Time, Value, State, Entity } from './types' 2 | import { Vault } from './vault' 3 | import { lerp, degreeLerp, radianLerp } from './lerp' 4 | import { quatSlerp } from './slerp' 5 | 6 | interface Config { 7 | autoCorrectTimeOffset?: boolean 8 | } 9 | 10 | /** A Snapshot Interpolation library. */ 11 | export class SnapshotInterpolation { 12 | /** Access the vault. */ 13 | public vault = new Vault() 14 | private _interpolationBuffer = 100 15 | private _timeOffset = -1 16 | /** The current server time based on the current snapshot interpolation. */ 17 | public serverTime = 0 18 | 19 | public config: Config 20 | 21 | constructor(serverFPS?: number | null, config: Config = {}) { 22 | if (serverFPS) this._interpolationBuffer = (1000 / serverFPS) * 3 23 | this.config = { autoCorrectTimeOffset: true, ...config } 24 | } 25 | 26 | public get interpolationBuffer() { 27 | return { 28 | /** Get the Interpolation Buffer time in milliseconds. */ 29 | get: () => this._interpolationBuffer, 30 | /** Set the Interpolation Buffer time in milliseconds. */ 31 | set: (milliseconds: number) => { 32 | this._interpolationBuffer = milliseconds 33 | } 34 | } 35 | } 36 | 37 | /** Get the current time in milliseconds. */ 38 | public static Now() { 39 | return Date.now() // - Date.parse('01 Jan 2020') 40 | } 41 | 42 | /** 43 | * Get the time offset between client and server (inclusive latency). 44 | * If the client and server time are in sync, timeOffset will be the latency. 45 | */ 46 | public get timeOffset() { 47 | return this._timeOffset 48 | } 49 | 50 | /** Create a new ID */ 51 | public static NewId() { 52 | return Math.random().toString(36).substr(2, 6) 53 | } 54 | 55 | public get snapshot() { 56 | return { 57 | /** Create the snapshot on the server. */ 58 | create: (state: State | { [key: string]: State }): Snapshot => SnapshotInterpolation.CreateSnapshot(state), 59 | /** Add the snapshot you received from the server to automatically calculate the interpolation with calcInterpolation() */ 60 | add: (snapshot: Snapshot): void => this.addSnapshot(snapshot) 61 | } 62 | } 63 | 64 | /** Create a new Snapshot */ 65 | public static CreateSnapshot(state: State | { [key: string]: State }): Snapshot { 66 | const check = (state: State) => { 67 | // check if state is an array 68 | if (!Array.isArray(state)) throw new Error('You have to pass an Array to createSnapshot()') 69 | 70 | // check if each entity has an id 71 | const withoutID = state.filter(e => typeof e.id !== 'string' && typeof e.id !== 'number') 72 | //console.log(withoutID) 73 | if (withoutID.length > 0) throw new Error('Each Entity needs to have a id') 74 | } 75 | 76 | if (Array.isArray(state)) { 77 | check(state) 78 | } else { 79 | Object.keys(state).forEach(key => { 80 | check(state[key]) 81 | }) 82 | } 83 | 84 | return { 85 | id: SnapshotInterpolation.NewId(), 86 | time: SnapshotInterpolation.Now(), 87 | state: state 88 | } 89 | } 90 | 91 | private addSnapshot(snapshot: Snapshot): void { 92 | const timeNow = SnapshotInterpolation.Now() 93 | const timeSnapshot = snapshot.time 94 | 95 | if (this._timeOffset === -1) { 96 | // the time offset between server and client is calculated, 97 | // by subtracting the current client date from the server time of the 98 | // first snapshot 99 | this._timeOffset = timeNow - timeSnapshot 100 | } 101 | 102 | // correct time offset 103 | if (this.config?.autoCorrectTimeOffset === true) { 104 | const timeOffset = timeNow - timeSnapshot 105 | const timeDifference = Math.abs(this._timeOffset - timeOffset) 106 | if (timeDifference > 50) this._timeOffset = timeOffset 107 | } 108 | 109 | this.vault.add(snapshot) 110 | } 111 | 112 | /** Interpolate between two snapshots give the percentage or time. */ 113 | public interpolate( 114 | snapshotA: Snapshot, 115 | snapshotB: Snapshot, 116 | timeOrPercentage: number, 117 | parameters: string, 118 | deep: string = '' 119 | ): InterpolatedSnapshot { 120 | return this._interpolate(snapshotA, snapshotB, timeOrPercentage, parameters, deep) 121 | } 122 | 123 | private _interpolate( 124 | snapshotA: Snapshot, 125 | snapshotB: Snapshot, 126 | timeOrPercentage: number, 127 | parameters: string, 128 | deep: string 129 | ): InterpolatedSnapshot { 130 | const sorted = [snapshotA, snapshotB].sort((a, b) => b.time - a.time) 131 | const params = parameters.trim().replace(/\W+/, ' ').split(' ') 132 | 133 | const newer: Snapshot = sorted[0] 134 | const older: Snapshot = sorted[1] 135 | 136 | const t0: Time = newer.time 137 | const t1: Time = older.time 138 | /** 139 | * If <= it is in percentage 140 | * else it is the server time 141 | */ 142 | const tn: number = timeOrPercentage // serverTime is between t0 and t1 143 | 144 | // THE TIMELINE 145 | // t = time (serverTime) 146 | // p = entity position 147 | // ------ t1 ------ tn --- t0 ----->> NOW 148 | // ------ p1 ------ pn --- p0 ----->> NOW 149 | // ------ 0% ------ x% --- 100% --->> NOW 150 | const zeroPercent = tn - t1 151 | const hundredPercent = t0 - t1 152 | const pPercent = timeOrPercentage <= 1 ? timeOrPercentage : zeroPercent / hundredPercent 153 | 154 | this.serverTime = lerp(t1, t0, pPercent) 155 | 156 | const lerpFnc = (method: string, start: Value, end: Value, t: number) => { 157 | if (typeof start === 'undefined' || typeof end === 'undefined') return 158 | 159 | if (typeof start === 'string' || typeof end === 'string') throw new Error(`Can't interpolate string!`) 160 | 161 | if (typeof start === 'number' && typeof end === 'number') { 162 | if (method === 'linear') return lerp(start, end, t) 163 | else if (method === 'deg') return degreeLerp(start, end, t) 164 | else if (method === 'rad') return radianLerp(start, end, t) 165 | } 166 | 167 | if (typeof start === 'object' && typeof end === 'object') { 168 | if (method === 'quat') return quatSlerp(start, end, t) 169 | } 170 | 171 | throw new Error(`No lerp method "${method}" found!`) 172 | } 173 | 174 | if (!Array.isArray(newer.state) && deep === '') throw new Error('You forgot to add the "deep" parameter.') 175 | 176 | if (Array.isArray(newer.state) && deep !== '') throw new Error('No "deep" needed it state is an array.') 177 | 178 | const newerState: State = Array.isArray(newer.state) ? newer.state : newer.state[deep] 179 | const olderState: State = Array.isArray(older.state) ? older.state : older.state[deep] 180 | 181 | let tmpSnapshot: Snapshot = JSON.parse(JSON.stringify({ ...newer, state: newerState })) 182 | 183 | newerState.forEach((e: Entity, i: number) => { 184 | const id = e.id 185 | const other: Entity | undefined = olderState.find((e: any) => e.id === id) 186 | if (!other) return 187 | 188 | params.forEach(p => { 189 | // TODO yandeu: improve this code 190 | const match = p.match(/\w\(([\w]+)\)/) 191 | const lerpMethod = match ? match?.[1] : 'linear' 192 | if (match) p = match?.[0].replace(/\([\S]+$/gm, '') 193 | 194 | const p0 = e?.[p] 195 | const p1 = other?.[p] 196 | 197 | const pn = lerpFnc(lerpMethod, p1, p0, pPercent) 198 | if (Array.isArray(tmpSnapshot.state)) tmpSnapshot.state[i][p] = pn 199 | }) 200 | }) 201 | 202 | const interpolatedSnapshot: InterpolatedSnapshot = { 203 | state: tmpSnapshot.state as State, 204 | percentage: pPercent, 205 | newer: newer.id, 206 | older: older.id 207 | } 208 | 209 | return interpolatedSnapshot 210 | } 211 | 212 | /** Get the calculated interpolation on the client. */ 213 | public calcInterpolation(parameters: string, deep: string = ''): InterpolatedSnapshot | undefined { 214 | // get the snapshots [this._interpolationBuffer] ago 215 | const serverTime = SnapshotInterpolation.Now() - this._timeOffset - this._interpolationBuffer 216 | 217 | const shots = this.vault.get(serverTime) 218 | if (!shots) return 219 | 220 | const { older, newer } = shots 221 | if (!older || !newer) return 222 | 223 | return this._interpolate(newer, older, serverTime, parameters, deep) 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type Value = number | string | Quat | undefined 2 | 3 | export interface Entity { 4 | id: string 5 | [key: string]: Value 6 | } 7 | 8 | export type ID = string 9 | export type Time = number 10 | export type State = Entity[] 11 | 12 | export interface Snapshot { 13 | id: ID 14 | time: Time 15 | state: State | { [key: string]: State } 16 | } 17 | 18 | export interface InterpolatedSnapshot { 19 | state: State 20 | percentage: number 21 | older: ID 22 | newer: ID 23 | } 24 | 25 | export type Quat = { x: number; y: number; z: number; w: number } 26 | -------------------------------------------------------------------------------- /src/vault.ts: -------------------------------------------------------------------------------- 1 | import { Snapshot, ID } from './types' 2 | 3 | /** A save place to store your snapshots. */ 4 | export class Vault { 5 | private _vault: Snapshot[] = [] 6 | private _vaultSize: number = 120 7 | 8 | /** Get a Snapshot by its ID. */ 9 | getById(id: ID): Snapshot { 10 | return this._vault.filter(snapshot => snapshot.id === id)?.[0] 11 | } 12 | 13 | /** Clear this Vault */ 14 | clear(): void { 15 | this._vault = [] 16 | } 17 | 18 | /** Get the latest snapshot */ 19 | get(): Snapshot | undefined 20 | /** Get the two snapshots around a specific time */ 21 | get(time: number): { older: Snapshot; newer: Snapshot } | undefined 22 | /** Get the closest snapshot to e specific time */ 23 | get(time: number, closest: boolean): Snapshot | undefined 24 | 25 | get(time?: number, closest?: boolean) { 26 | // zero index is the newest snapshot 27 | const sorted = this._vault.sort((a, b) => b.time - a.time) 28 | if (typeof time === 'undefined') return sorted[0] 29 | 30 | for (let i = 0; i < sorted.length; i++) { 31 | const snap = sorted[i] 32 | if (snap.time <= time) { 33 | const snaps = { older: sorted[i], newer: sorted[i - 1] } 34 | if (closest) { 35 | const older = Math.abs(time - snaps.older.time) 36 | const newer = Math.abs(time - snaps.newer?.time) 37 | if (isNaN(newer)) return snaps.older 38 | else if (newer <= older) return snaps.older 39 | else return snaps.newer 40 | } 41 | return snaps 42 | } 43 | } 44 | return 45 | } 46 | 47 | /** Add a snapshot to the vault. */ 48 | add(snapshot: Snapshot) { 49 | if (this._vault.length > this._vaultSize - 1) { 50 | // remove the oldest snapshot 51 | this._vault.sort((a, b) => a.time - b.time).shift() 52 | } 53 | this._vault.push(snapshot) 54 | } 55 | 56 | /** Get the current capacity (size) of the vault. */ 57 | public get size() { 58 | return this._vault.length 59 | } 60 | 61 | /** Set the max capacity (size) of the vault. */ 62 | setMaxSize(size: number) { 63 | this._vaultSize = size 64 | } 65 | 66 | /** Get the max capacity (size) of the vault. */ 67 | getMaxSize() { 68 | return this._vaultSize 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /test/compression.test.js: -------------------------------------------------------------------------------- 1 | const { SnapshotInterpolation } = require('../lib/index') 2 | const { BufferSchema, Model, uint8, int16, uint64, string8 } = require('@geckos.io/typed-array-buffer-schema') 3 | 4 | const tick = 1000 / 20 5 | 6 | const delay = () => { 7 | return new Promise(resolve => { 8 | setTimeout(() => { 9 | resolve() 10 | }, tick) 11 | }) 12 | } 13 | 14 | const SI = new SnapshotInterpolation(null, { autoCorrectTimeOffset: false }) // false, for testing 15 | SI.interpolationBuffer.set(30) // this is only that low for testing 16 | 17 | const playerSchema = BufferSchema.schema('player', { 18 | id: uint8, 19 | x: { type: int16, digits: 1 }, 20 | y: { type: int16, digits: 1 } 21 | }) 22 | 23 | const snapshotSchema = BufferSchema.schema('snapshot', { 24 | id: { type: string8, length: 6 }, 25 | time: uint64, 26 | state: { players: [playerSchema] } 27 | }) 28 | 29 | const snapshotModel = new Model(snapshotSchema) 30 | 31 | const buffer = new Array(2) 32 | 33 | test('should add 2 shots', async () => { 34 | const snapshot0 = SI.snapshot.create({ 35 | players: [ 36 | { id: 0, x: 0, y: 0 }, 37 | { id: 1, x: 0, y: 0 } 38 | ] 39 | }) 40 | buffer[0] = snapshotModel.toBuffer(snapshot0) 41 | 42 | await delay() 43 | 44 | const snapshot1 = SI.snapshot.create({ 45 | players: [ 46 | { id: 0, x: 10, y: 5 }, 47 | { id: 1, x: 20, y: 50 } 48 | ] 49 | }) 50 | buffer[1] = snapshotModel.toBuffer(snapshot1) 51 | }) 52 | 53 | test('should decompress buffer and add snapshots', async () => { 54 | const snapshot0 = snapshotModel.fromBuffer(buffer[0]) 55 | const snapshot1 = snapshotModel.fromBuffer(buffer[1]) 56 | SI.addSnapshot(snapshot0) 57 | SI.addSnapshot(snapshot1) 58 | 59 | await delay() 60 | 61 | expect(SI.vault.size).toBe(2) 62 | expect(snapshot0.state.players[0].x).toBe(0) 63 | }) 64 | 65 | test('should interpolate the players array', () => { 66 | const snap = SI.calcInterpolation('x y', 'players') 67 | expect(snap.state[0].x > 0 && snap.state[0].x < 10).toBeTruthy() 68 | expect(snap.state[1].id).toBe(1) 69 | }) 70 | -------------------------------------------------------------------------------- /test/deep.test.js: -------------------------------------------------------------------------------- 1 | const { SnapshotInterpolation } = require('../lib/index') 2 | 3 | const tick = 1000 / 20 4 | 5 | const delay = () => { 6 | return new Promise(resolve => { 7 | setTimeout(() => { 8 | resolve() 9 | }, tick) 10 | }) 11 | } 12 | 13 | const SI = new SnapshotInterpolation() 14 | SI.interpolationBuffer.set(30) // this is only that low for testing 15 | 16 | test('should add 2 shots', async () => { 17 | SI.addSnapshot( 18 | SI.snapshot.create({ 19 | players: [ 20 | { id: 0, x: 0, y: 0 }, 21 | { id: 1, x: 0, y: 0 } 22 | ] 23 | }) 24 | ) 25 | 26 | await delay() 27 | 28 | SI.addSnapshot( 29 | SI.snapshot.create({ 30 | players: [ 31 | { id: 0, x: 10, y: 5 }, 32 | { id: 1, x: 20, y: 50 } 33 | ] 34 | }) 35 | ) 36 | 37 | expect(SI.vault.size).toBe(2) 38 | }) 39 | 40 | test('should interpolate the players array', () => { 41 | const snap = SI.calcInterpolation('x y', 'players') 42 | expect(snap.state[0].x > 0 && snap.state[0].x < 10).toBeTruthy() 43 | expect(snap.state[1].id).toBe(1) 44 | }) 45 | 46 | test('should throw', () => { 47 | expect(() => { 48 | SI.calcInterpolation('x y') 49 | }).toThrow() 50 | }) 51 | -------------------------------------------------------------------------------- /test/lerp.test.js: -------------------------------------------------------------------------------- 1 | const { lerp, degreeLerp, radianLerp } = require('../lib/lerp') 2 | 3 | test('lerp should be 0.5', () => { 4 | expect(lerp(0, 1, 0.5)).toBe(0.5) 5 | }) 6 | 7 | test('degreeLerp should be 90', () => { 8 | expect(degreeLerp(0, 180, 0.5)).toBe(90) 9 | }) 10 | 11 | test('degreeLerp should be -22.5', () => { 12 | expect(degreeLerp(-360, -45, 0.5)).toBe(-22.5) 13 | }) 14 | 15 | test('degreeLerp should be 230', () => { 16 | expect(degreeLerp(540, 270, 0.5)).toBe(225) 17 | }) 18 | 19 | test('radianLerp should be Math.PI * 0.75', () => { 20 | expect(radianLerp(Math.PI * 0.5, Math.PI * 1, 0.5)).toBe(Math.PI * 0.75) 21 | }) 22 | 23 | test('radianLerp should be ~-Math.PI / 16', () => { 24 | const rad = radianLerp(-2 * Math.PI, -Math.PI / 8, 0.5).toFixed(4) 25 | const res = (-Math.PI / 16).toFixed(4) 26 | expect(rad).toBe(res) 27 | }) 28 | 29 | test('radianLerp should be ~-Math.PI / 16', () => { 30 | const rad = radianLerp(3 * Math.PI, (Math.PI * 3) / 2, 0.5).toFixed(4) 31 | const res = ((Math.PI / 4) * 5).toFixed(4) 32 | expect(rad).toBe(res) 33 | }) 34 | -------------------------------------------------------------------------------- /test/slerp.test.js: -------------------------------------------------------------------------------- 1 | const { quatSlerp } = require('../lib/slerp') 2 | 3 | const quatIsEqual = (q1, q2) => { 4 | expect(q1.x).toBeCloseTo(q2.x, 3) 5 | expect(q1.y).toBeCloseTo(q2.y, 3) 6 | expect(q1.z).toBeCloseTo(q2.z, 3) 7 | expect(q1.w).toBeCloseTo(q2.w, 3) 8 | } 9 | 10 | test('quatSlerp 1', () => { 11 | const qa = { x: 0.002, y: 0.678, z: -0.226, w: -0.7 } 12 | const qb = { x: 0.003, y: 0.893, z: -0.298, w: 0.337 } 13 | let q = quatSlerp(qa, qb, 0) 14 | quatIsEqual(q, qa) 15 | q = quatSlerp(qa, qb, 1) 16 | quatIsEqual(q, qb) 17 | q = quatSlerp(qa, qb, 0.5) 18 | qExpected = { x: 0.002949446515147865, y: 0.9267160950594592, z: -0.3091019947874962, w: -0.21412981699973496 } 19 | quatIsEqual(q, qExpected) 20 | }) 21 | 22 | test('quatSlerp 2', () => { 23 | const qa = { x: 1, y: 1, z: 1, w: 1 } 24 | const qb = { x: 0.5, y: 0.5, z: 0.5, w: 0.5 } 25 | const q = quatSlerp(qa, qb, 0.5) 26 | quatIsEqual(q, { x: 0.5, y: 0.5, z: 0.5, w: 0.5 }) 27 | }) 28 | 29 | test('quatSlerp 3', () => { 30 | const qa = { x: 0, y: 0.99999999, z: 0, w: 0 } 31 | const qb = { x: 0, y: 1, z: 0, w: 0 } 32 | const q = quatSlerp(qa, qb, 0.5) 33 | quatIsEqual(q, { x: 0, y: 0.9999999975, z: 0, w: 0 }) 34 | }) 35 | -------------------------------------------------------------------------------- /test/snapshot.test.js: -------------------------------------------------------------------------------- 1 | const { SnapshotInterpolation } = require('../lib/index') 2 | 3 | const SI = new SnapshotInterpolation() 4 | SI.interpolationBuffer.set(30) // this is only that low for testing 5 | const tick = 1000 / 20 6 | let snapshot 7 | let id1 8 | let id2 9 | let interpolatedSnapshot 10 | 11 | const delay = () => { 12 | return new Promise(resolve => { 13 | setTimeout(() => { 14 | resolve() 15 | }, tick) 16 | }) 17 | } 18 | 19 | test('should be initialized', () => { 20 | expect(SI).not.toBeUndefined() 21 | }) 22 | 23 | test('initialize with server fps', () => { 24 | const SI = new SnapshotInterpolation(20) 25 | const buffer = SI.interpolationBuffer.get() 26 | expect(buffer).toBe(150) 27 | }) 28 | 29 | test('calc interpolated without any data', () => { 30 | interpolatedSnapshot = SI.calcInterpolation('x y d(deg) r(rad) q(quat)') 31 | expect(interpolatedSnapshot).toBeUndefined() 32 | }) 33 | 34 | test('should create and add snapshot', async () => { 35 | await delay() 36 | snapshot = SI.snapshot.create([{ id: 'hero', x: 0, y: 0, d: 0, r: 0, q: { x: 0, y: 0, z: 0, w: 1 } }]) 37 | id1 = snapshot.id 38 | SI.snapshot.add(snapshot) 39 | expect(snapshot).not.toBeUndefined() 40 | }) 41 | 42 | test('calc interpolated with not enough data', async () => { 43 | await delay() 44 | interpolatedSnapshot = SI.calcInterpolation('x y d(deg) r(rad) q(quat)') 45 | expect(interpolatedSnapshot).toBeUndefined() 46 | }) 47 | 48 | test('snapshot id should be 6 chars long', () => { 49 | expect(snapshot.id.length).toBe(6) 50 | }) 51 | 52 | test('vault should have a size of one', () => { 53 | expect(SI.vault.size).toBe(1) 54 | }) 55 | 56 | test('getting latest snapshot should have same id', () => { 57 | const s = SI.vault.get() 58 | expect(s.id).toBe(snapshot.id) 59 | }) 60 | 61 | test('each entity should always have id', () => { 62 | expect(() => { 63 | SnapshotInterpolation.CreateSnapshot([{ x: 10, y: 10 }]) 64 | }).toThrow() 65 | }) 66 | 67 | test('worldState should be an array', () => { 68 | expect(() => { 69 | SI.snapshot.create({ 70 | id: 'hero', 71 | x: 10, 72 | y: 10, 73 | d: 90, 74 | r: Math.PI / 4, 75 | q: { x: 0, y: 0.707, z: 0, w: 0.707 } 76 | }) 77 | }).toThrow() 78 | }) 79 | 80 | test('should create and add another snapshot', async () => { 81 | await delay() 82 | snapshot = SI.snapshot.create([ 83 | { 84 | id: 'hero', 85 | x: 10, 86 | y: 10, 87 | d: 90, 88 | r: Math.PI / 4, 89 | q: { x: 0, y: 0.707, z: 0, w: 0.707 } 90 | }, 91 | { id: 'enemyOne' } 92 | ]) 93 | id2 = snapshot.id 94 | SI.snapshot.add(snapshot) 95 | expect(SI.vault.size).toBe(2) 96 | }) 97 | 98 | test('should get interpolated value', () => { 99 | interpolatedSnapshot = SI.calcInterpolation('x y d(deg) r(rad) q(quat)') 100 | expect(interpolatedSnapshot).not.toBeUndefined() 101 | }) 102 | 103 | test('can not interpolate strings', () => { 104 | expect(() => { 105 | SI.calcInterpolation('id') 106 | }).toThrow() 107 | }) 108 | 109 | test('can not interpolated unknown method', () => { 110 | expect(() => { 111 | SI.calcInterpolation('x y d(mojito)') 112 | }).toThrow() 113 | }) 114 | 115 | test('interpolate the value p, that is not there', () => { 116 | const snap = SI.calcInterpolation('x y d(deg) p') 117 | expect(snap.state[0].p).toBeUndefined() 118 | }) 119 | 120 | test('should have same id as original snapshots', () => { 121 | const mergedId1 = interpolatedSnapshot.older + interpolatedSnapshot.newer 122 | const mergedId2 = id1 + id2 123 | expect(mergedId1).toBe(mergedId2) 124 | }) 125 | 126 | test('values should be interpolated', () => { 127 | const entity = interpolatedSnapshot.state.find(e => e.id === 'hero') 128 | 129 | expect(entity.x > 0 && entity.x < 10).toBeTruthy() 130 | expect(entity.r > 0 && entity.r < Math.PI / 4).toBeTruthy() 131 | expect(entity.d > 0 && entity.d < 90).toBeTruthy() 132 | expect(entity.q.w < 1 && entity.q.w > 0.707).toBeTruthy() 133 | expect(entity.q.y > 0 && entity.q.y < 0.707).toBeTruthy() 134 | }) 135 | 136 | test('timeOffset should >= 0', () => { 137 | const timeOffset = SI.timeOffset 138 | expect(timeOffset >= 0).toBeTruthy() 139 | }) 140 | 141 | test('custom interpolation', () => { 142 | const shots = SI.vault.get(new Date().getTime() - 50) 143 | const interpolated = SI.interpolate(shots.older, shots.newer, 0.5, 'x y') 144 | 145 | const x = interpolated.state[0].x 146 | expect(x > 0 && x < 10).toBeTruthy() 147 | expect(interpolated.percentage).toBe(0.5) 148 | }) 149 | 150 | test('custom interpolation (with deep)', () => { 151 | const shots = SI.vault.get(new Date().getTime() - 50) 152 | 153 | expect(() => { 154 | SI.interpolate(shots.older, shots.newer, 0.5, 'x y', 'players') 155 | }).toThrow() 156 | }) 157 | -------------------------------------------------------------------------------- /test/vault.test.js: -------------------------------------------------------------------------------- 1 | const { Vault, SnapshotInterpolation } = require('../lib/index') 2 | 3 | const vault = new Vault() 4 | const tick = 1000 / 20 5 | let snapshotId 6 | 7 | const delay = () => { 8 | return new Promise(resolve => { 9 | setTimeout(() => { 10 | resolve() 11 | }, tick) 12 | }) 13 | } 14 | 15 | test('empty vault size should be 0', () => { 16 | expect(vault.size).toBe(0) 17 | }) 18 | 19 | test('get a snapshot that does not yet exist', () => { 20 | const shot = vault.get(new Date().getTime() - tick * 3, true) 21 | expect(shot).toBeUndefined() 22 | }) 23 | 24 | test('max vault size should be 120', () => { 25 | expect(vault.getMaxSize()).toBe(120) 26 | }) 27 | 28 | test('max vault size should be increased to 180', () => { 29 | vault.setMaxSize(180) 30 | expect(vault.getMaxSize()).toBe(180) 31 | }) 32 | 33 | test('add a snapshot to the vault', async () => { 34 | await delay() 35 | const snapshot = SnapshotInterpolation.CreateSnapshot([{ id: 'hero', x: 10, y: 10 }]) 36 | snapshotId = snapshot.id 37 | vault.add(snapshot) 38 | expect(vault.size).toBe(1) 39 | }) 40 | 41 | test('decrease max vault size', () => { 42 | vault.setMaxSize(2) 43 | expect(vault.getMaxSize()).toBe(2) 44 | }) 45 | 46 | test('get a snapshot by its id', () => { 47 | const snapshot = vault.getById(snapshotId) 48 | expect(snapshot.id).toBe(snapshotId) 49 | }) 50 | 51 | test('add more snapshots to the vault', async () => { 52 | vault.setMaxSize(4) 53 | await delay() 54 | vault.add(SnapshotInterpolation.CreateSnapshot([{ id: 'hero', x: 20, y: 20 }])) 55 | await delay() 56 | vault.add(SnapshotInterpolation.CreateSnapshot([{ id: 'hero', x: 30, y: 30 }])) 57 | await delay() 58 | vault.add(SnapshotInterpolation.CreateSnapshot([{ id: 'hero', x: 40, y: 40 }])) 59 | await delay() 60 | vault.add(SnapshotInterpolation.CreateSnapshot([{ id: 'hero', x: 50, y: 50 }])) 61 | expect(vault.size).toBe(4) 62 | }) 63 | 64 | test('get some closest snapshot to a specific time', () => { 65 | const shot1 = vault.get(new Date().getTime() - tick * 3 + 10, true) 66 | const shot2 = vault.get(new Date().getTime() - tick * 3 + 20, true) 67 | const shot3 = vault.get(new Date().getTime() - tick * 3 + 30, true) 68 | expect(shot1.id.length + shot2.id.length + shot3.id.length).toBe(18) 69 | }) 70 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2019", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | 7 | "rootDir": "src", 8 | "outDir": "lib", 9 | 10 | "strict": true, 11 | "esModuleInterop": true, 12 | 13 | "sourceMap": true, 14 | "declaration": true, 15 | "declarationMap": true 16 | }, 17 | "include": ["src/**/*"], 18 | "exclude": ["node_modules", "**/*.spec.ts"] 19 | } 20 | -------------------------------------------------------------------------------- /webpack.bundle.js: -------------------------------------------------------------------------------- 1 | const TerserPlugin = require("terser-webpack-plugin"); 2 | const path = require('path') 3 | 4 | module.exports = { 5 | mode: 'production', 6 | entry: './src/bundle.ts', 7 | output: { 8 | filename: 'snapshot-interpolation.js', 9 | path: path.resolve(__dirname, 'bundle'), 10 | library: 'Snap', 11 | libraryExport: 'default' 12 | }, 13 | resolve: { 14 | extensions: ['.ts', '.tsx', '.js'] 15 | }, 16 | module: { 17 | rules: [{ test: /\.tsx?$/, loader: 'ts-loader' }] 18 | }, 19 | optimization: { 20 | minimize: true, 21 | minimizer: [ 22 | new TerserPlugin({ 23 | terserOptions: { 24 | format: { 25 | comments: /@license/i, 26 | }, 27 | }, 28 | extractComments: false, 29 | }), 30 | ], 31 | }, 32 | } 33 | -------------------------------------------------------------------------------- /webpack.bundle.tmp.js: -------------------------------------------------------------------------------- 1 | const config = require('./webpack.bundle.js') 2 | 3 | module.exports = { ...config, output: { ...config.output, filename: 'snapshot-interpolation.tmp.js' } } 4 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const HtmlWebpackPlugin = require('html-webpack-plugin') 3 | 4 | module.exports = { 5 | mode: 'development', 6 | devtool: 'inline-source-map', 7 | entry: './example/client/index.js', 8 | output: { 9 | filename: 'bundle.js', 10 | path: path.resolve(__dirname, 'example/dist'), 11 | }, 12 | plugins: [new HtmlWebpackPlugin()], 13 | } 14 | --------------------------------------------------------------------------------