├── .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 |
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 | [](https://www.npmjs.com/package/@geckos.io/snapshot-interpolation)
12 | [](https://github.com/geckosio/snapshot-interpolation/actions?query=workflow%3ACI)
13 | [](https://github.com/geckosio/snapshot-interpolation/commits/master)
14 | [](https://www.npmjs.com/package/@geckos.io/snapshot-interpolation)
15 | [](https://codecov.io/gh/geckosio/snapshot-interpolation)
16 | [](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 |
--------------------------------------------------------------------------------