spoint 0.1.671 → 0.1.673
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spoint",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.673",
|
|
4
4
|
"description": "Physics and netcode SDK for multiplayer game servers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
@@ -84,7 +84,6 @@
|
|
|
84
84
|
"@gltf-transform/functions": "^4.4.0",
|
|
85
85
|
"@pixiv/three-vrm": "^3.5.5",
|
|
86
86
|
"@three.ez/instanced-mesh": "^0.3.15",
|
|
87
|
-
"alea": "^1.0.1",
|
|
88
87
|
"bvh.js": "^0.0.13",
|
|
89
88
|
"draco3d": "^1.5.7",
|
|
90
89
|
"draco3dgltf": "^1.5.7",
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// either driver.
|
|
6
6
|
//
|
|
7
7
|
// The one deliberate, load-bearing difference from TickSystem: dt is NEVER derived from wall-clock
|
|
8
|
-
// measurement. TickSystem's dilationFactor (
|
|
8
|
+
// measurement. TickSystem's dilationFactor (_onTickMeasured/_onInterval) adaptively shrinks dt under
|
|
9
9
|
// server load so ONE authoritative server stays real-time-paced -- exactly the behavior a lockstep
|
|
10
10
|
// peer must never exhibit, since every peer has to independently derive the IDENTICAL dt sequence
|
|
11
11
|
// from tick number alone (deterministic-fixed-point-lockstep-architecture-for-rts-fighting's own
|
|
@@ -15,103 +15,34 @@
|
|
|
15
15
|
// perf.now() delta would reintroduce the exact wall-clock leakage this row exists to bypass, so this
|
|
16
16
|
// driver intentionally does NOT accumulate/measure real elapsed time to decide dt, only to decide
|
|
17
17
|
// WHEN to fire the next already-fixed-size tick (wall clock only paces cadence, never sizes the step).
|
|
18
|
-
|
|
18
|
+
//
|
|
19
|
+
// Shares TickSystemBase's accumulator/catch-up scheduling loop with TickSystem.js verbatim -- only
|
|
20
|
+
// _computeDt/_onTickMeasured differ (see that file's header comment for the shared-loop rationale).
|
|
21
|
+
import { TickSystemBase } from './TickSystemBase.js'
|
|
22
|
+
|
|
23
|
+
export class LockstepTickSystem extends TickSystemBase {
|
|
19
24
|
constructor(tickRate = 60) {
|
|
20
|
-
|
|
21
|
-
this.
|
|
22
|
-
|
|
23
|
-
this
|
|
24
|
-
this.callbacks = []
|
|
25
|
-
this._state = 'stopped'
|
|
26
|
-
this._reloadResolve = null
|
|
27
|
-
this._tickInProgress = false
|
|
28
|
-
// Fixed at 1.0, permanently -- unlike TickSystem, there is no _measureTick/_onInterval-driven
|
|
29
|
-
// mutation path at all, so this can never silently drift under load. Kept as a real field (not a
|
|
30
|
-
// getter constant) purely so any code doing `tickSystem.dilationFactor` for logging/diagnostics
|
|
31
|
-
// reads the same shape as TickSystem instead of throwing on a missing property.
|
|
32
|
-
this.dilationFactor = 1.0
|
|
33
|
-
this._dilationCallbacks = []
|
|
34
|
-
this._accumulator = 0
|
|
35
|
-
this._intervalHandle = null
|
|
25
|
+
super(tickRate)
|
|
26
|
+
this._tickErrorTag = '[lockstep-tick]'
|
|
27
|
+
// dilationFactor stays fixed at 1.0 permanently -- unlike TickSystem, there is no
|
|
28
|
+
// _onTickMeasured-driven mutation path at all, so this can never silently drift under load.
|
|
36
29
|
}
|
|
37
30
|
|
|
38
|
-
get running() { return this._state === 'running' }
|
|
39
|
-
|
|
40
31
|
// Kept for API parity with TickSystem.onDilation -- lockstep mode never dilates, so a registered
|
|
41
|
-
// callback simply never fires
|
|
42
|
-
// (TickHandler.js's server.js:297-style
|
|
43
|
-
// must not throw when handed a
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
start() {
|
|
52
|
-
if (this.running) return
|
|
53
|
-
this._state = 'running'
|
|
54
|
-
this.lastTickTime = performance.now()
|
|
55
|
-
this._accumulator = 0
|
|
56
|
-
const intervalMs = Math.max(1, this.tickDuration / 2)
|
|
57
|
-
this._intervalHandle = setInterval(() => this._onInterval(), intervalMs)
|
|
58
|
-
if (this._intervalHandle.unref) this._intervalHandle.unref()
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
_onInterval() {
|
|
62
|
-
if (!this.running) return
|
|
63
|
-
const now = performance.now()
|
|
64
|
-
this._accumulator += now - this.lastTickTime
|
|
65
|
-
this.lastTickTime = now
|
|
66
|
-
const maxSteps = 4
|
|
67
|
-
const maxAccumulated = this.tickDuration * maxSteps
|
|
68
|
-
if (this._accumulator > maxAccumulated) this._accumulator = maxAccumulated
|
|
69
|
-
let steps = 0
|
|
70
|
-
const isPaused = this._state === 'paused'
|
|
71
|
-
while (this._accumulator >= this.tickDuration && !isPaused && steps < maxSteps) {
|
|
72
|
-
// dt is always the fixed, undilated tick duration -- the entire point of this driver. A stall
|
|
73
|
-
// (debugger pause, GC, slow machine) changes HOW MANY fixed-size ticks fire in this catch-up
|
|
74
|
-
// burst (same maxSteps cap as TickSystem), never the SIZE of any individual tick's dt.
|
|
75
|
-
this._tickInProgress = true
|
|
76
|
-
this.currentTick++
|
|
77
|
-
this._accumulator -= this.tickDuration
|
|
78
|
-
for (const callback of this.callbacks) {
|
|
79
|
-
try {
|
|
80
|
-
callback(this.currentTick, this.tickDuration / 1000)
|
|
81
|
-
} catch (e) {
|
|
82
|
-
console.error('[lockstep-tick]', e?.stack || e?.message || e)
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
this._tickInProgress = false
|
|
86
|
-
if (this._reloadResolve) {
|
|
87
|
-
this._reloadResolve()
|
|
88
|
-
this._reloadResolve = null
|
|
89
|
-
}
|
|
90
|
-
steps++
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
pauseForReload() {
|
|
95
|
-
this._state = 'paused'
|
|
96
|
-
if (!this._tickInProgress) return Promise.resolve()
|
|
97
|
-
return new Promise(resolve => { this._reloadResolve = resolve })
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
resumeAfterReload() {
|
|
101
|
-
this._state = 'running'
|
|
102
|
-
this.lastTickTime = performance.now()
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
stop() {
|
|
106
|
-
this._state = 'stopped'
|
|
107
|
-
if (this._intervalHandle) {
|
|
108
|
-
clearInterval(this._intervalHandle)
|
|
109
|
-
this._intervalHandle = null
|
|
110
|
-
}
|
|
32
|
+
// callback simply never fires (still pushed into _dilationCallbacks for parity, just never invoked).
|
|
33
|
+
// Real no-op, not a stub thrown away later: any shared caller (TickHandler.js's server.js:297-style
|
|
34
|
+
// wiring) that unconditionally calls tickSystem.onDilation(...) must not throw when handed a
|
|
35
|
+
// LockstepTickSystem instead of a TickSystem.
|
|
36
|
+
|
|
37
|
+
_computeDt() {
|
|
38
|
+
// dt is always the fixed, undilated tick duration -- the entire point of this driver. A stall
|
|
39
|
+
// (debugger pause, GC, slow machine) changes HOW MANY fixed-size ticks fire in this catch-up
|
|
40
|
+
// burst (same maxSteps cap as TickSystem), never the SIZE of any individual tick's dt.
|
|
41
|
+
return this.tickDuration / 1000
|
|
111
42
|
}
|
|
112
43
|
|
|
113
|
-
|
|
114
|
-
|
|
44
|
+
_onTickMeasured(_budgetMs) {
|
|
45
|
+
// no-op: this driver never adapts dt to measured tick cost (see header comment)
|
|
115
46
|
}
|
|
116
47
|
|
|
117
48
|
getTickDuration() {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { TickSystemBase } from './TickSystemBase.js'
|
|
2
|
+
|
|
1
3
|
const DILATION_WINDOW = 60
|
|
2
4
|
const DILATION_THRESHOLD = 0.85
|
|
3
5
|
const DILATION_MIN = 0.1
|
|
@@ -7,31 +9,21 @@ const DILATION_MIN_STEP = 0.01
|
|
|
7
9
|
const DILATION_MAX_STEP = 0.25
|
|
8
10
|
const DILATION_GAIN = 0.5
|
|
9
11
|
|
|
10
|
-
export class TickSystem {
|
|
12
|
+
export class TickSystem extends TickSystemBase {
|
|
11
13
|
// 60Hz default (was 128) -- mirrors src/sdk/server.js's config.tickRate||60; every real caller passes
|
|
12
14
|
// tickRate explicitly, this is only a defensive fallback for direct instantiation.
|
|
13
15
|
constructor(tickRate = 60) {
|
|
14
|
-
|
|
15
|
-
this.
|
|
16
|
-
this.currentTick = 0
|
|
17
|
-
this.lastTickTime = 0
|
|
18
|
-
this.callbacks = []
|
|
19
|
-
this._state = 'stopped'
|
|
20
|
-
this._reloadResolve = null
|
|
21
|
-
this._tickInProgress = false
|
|
22
|
-
this.dilationFactor = 1.0
|
|
23
|
-
this._dilationCallbacks = []
|
|
16
|
+
super(tickRate)
|
|
17
|
+
this._tickErrorTag = '[tick]'
|
|
24
18
|
this._tickBudgetMs = []
|
|
25
19
|
this._tickBudgetSum = 0
|
|
26
|
-
this._accumulator = 0
|
|
27
|
-
this._intervalHandle = null
|
|
28
20
|
}
|
|
29
21
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
22
|
+
_computeDt() {
|
|
23
|
+
return (this.tickDuration * this.dilationFactor) / 1000
|
|
24
|
+
}
|
|
33
25
|
|
|
34
|
-
|
|
26
|
+
_onTickMeasured(budget) {
|
|
35
27
|
this._tickBudgetMs.push(budget)
|
|
36
28
|
this._tickBudgetSum += budget
|
|
37
29
|
if (this._tickBudgetMs.length > DILATION_WINDOW) {
|
|
@@ -57,85 +49,6 @@ export class TickSystem {
|
|
|
57
49
|
}
|
|
58
50
|
}
|
|
59
51
|
|
|
60
|
-
onTick(callback) {
|
|
61
|
-
// dedup by identity: re-registering the same callback must not fire it N times/tick
|
|
62
|
-
if (this.callbacks.includes(callback)) return
|
|
63
|
-
this.callbacks.push(callback)
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
start() {
|
|
67
|
-
if (this.running) return
|
|
68
|
-
this._state = 'running'
|
|
69
|
-
this.lastTickTime = performance.now()
|
|
70
|
-
this._accumulator = 0
|
|
71
|
-
// Fixed-timestep scheduling: a single setInterval at roughly half the tick
|
|
72
|
-
// duration drives the loop; each firing consumes as many whole ticks as have
|
|
73
|
-
// accumulated (accumulator-based catch-up), instead of a setTimeout(...,1)/
|
|
74
|
-
// setImmediate busy-loop that drifts and burns CPU re-scheduling every ~1ms.
|
|
75
|
-
const intervalMs = Math.max(1, this.tickDuration / 2)
|
|
76
|
-
this._intervalHandle = setInterval(() => this._onInterval(), intervalMs)
|
|
77
|
-
if (this._intervalHandle.unref) this._intervalHandle.unref()
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
_onInterval() {
|
|
81
|
-
if (!this.running) return
|
|
82
|
-
const now = performance.now()
|
|
83
|
-
this._accumulator += now - this.lastTickTime
|
|
84
|
-
this.lastTickTime = now
|
|
85
|
-
const maxSteps = 4
|
|
86
|
-
// Cap the catch-up burst so a long stall (debugger pause, GC, reload) doesn't
|
|
87
|
-
// try to replay an unbounded backlog of ticks in one go.
|
|
88
|
-
const maxAccumulated = this.tickDuration * maxSteps
|
|
89
|
-
if (this._accumulator > maxAccumulated) this._accumulator = maxAccumulated
|
|
90
|
-
let steps = 0
|
|
91
|
-
const isPaused = this._state === 'paused'
|
|
92
|
-
while (this._accumulator >= this.tickDuration && !isPaused && steps < maxSteps) {
|
|
93
|
-
const dilatedDuration = this.tickDuration * this.dilationFactor
|
|
94
|
-
this._tickInProgress = true
|
|
95
|
-
this.currentTick++
|
|
96
|
-
this._accumulator -= this.tickDuration
|
|
97
|
-
const t0 = performance.now()
|
|
98
|
-
for (const callback of this.callbacks) {
|
|
99
|
-
// a throwing callback must not abort the loop / wedge pauseForReload's _tickInProgress
|
|
100
|
-
try {
|
|
101
|
-
callback(this.currentTick, dilatedDuration / 1000)
|
|
102
|
-
} catch (e) {
|
|
103
|
-
console.error('[tick]', e?.stack || e?.message || e)
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
this._measureTick(performance.now() - t0)
|
|
107
|
-
this._tickInProgress = false
|
|
108
|
-
if (this._reloadResolve) {
|
|
109
|
-
this._reloadResolve()
|
|
110
|
-
this._reloadResolve = null
|
|
111
|
-
}
|
|
112
|
-
steps++
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
pauseForReload() {
|
|
117
|
-
this._state = 'paused'
|
|
118
|
-
if (!this._tickInProgress) return Promise.resolve()
|
|
119
|
-
return new Promise(resolve => { this._reloadResolve = resolve })
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
resumeAfterReload() {
|
|
123
|
-
this._state = 'running'
|
|
124
|
-
this.lastTickTime = performance.now()
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
stop() {
|
|
128
|
-
this._state = 'stopped'
|
|
129
|
-
if (this._intervalHandle) {
|
|
130
|
-
clearInterval(this._intervalHandle)
|
|
131
|
-
this._intervalHandle = null
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
getTick() {
|
|
136
|
-
return this.currentTick
|
|
137
|
-
}
|
|
138
|
-
|
|
139
52
|
getTickDuration() {
|
|
140
53
|
return (this.tickDuration * this.dilationFactor) / 1000
|
|
141
54
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Shared fixed-timestep tick-scheduling base for TickSystem.js (adaptive, wall-clock-dilated) and
|
|
2
|
+
// LockstepTickSystem.js (fixed-dt, deterministic). Both drivers need the IDENTICAL accumulator/catch-up
|
|
3
|
+
// scheduling loop (a single setInterval at ~half the tick duration, consuming whole accumulated ticks
|
|
4
|
+
// per firing, capped at maxSteps=4 so a stall's catch-up burst is bounded) -- what differs is ONLY how
|
|
5
|
+
// each computes the dt handed to callbacks and whether it measures tick cost to adapt dilationFactor.
|
|
6
|
+
// Subclasses override _computeDt() (the per-tick dt in seconds) and _onTickMeasured(budgetMs) (called
|
|
7
|
+
// with each tick's real wall-clock cost, a no-op for the deterministic lockstep driver).
|
|
8
|
+
|
|
9
|
+
export class TickSystemBase {
|
|
10
|
+
constructor(tickRate = 60) {
|
|
11
|
+
this.tickRate = tickRate
|
|
12
|
+
this.tickDuration = 1000 / tickRate
|
|
13
|
+
this.currentTick = 0
|
|
14
|
+
this.lastTickTime = 0
|
|
15
|
+
this.callbacks = []
|
|
16
|
+
this._state = 'stopped'
|
|
17
|
+
this._reloadResolve = null
|
|
18
|
+
this._tickInProgress = false
|
|
19
|
+
this.dilationFactor = 1.0
|
|
20
|
+
this._dilationCallbacks = []
|
|
21
|
+
this._accumulator = 0
|
|
22
|
+
this._intervalHandle = null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get running() { return this._state === 'running' }
|
|
26
|
+
|
|
27
|
+
onDilation(cb) { this._dilationCallbacks.push(cb) }
|
|
28
|
+
|
|
29
|
+
onTick(callback) {
|
|
30
|
+
// dedup by identity: re-registering the same callback must not fire it N times/tick
|
|
31
|
+
if (this.callbacks.includes(callback)) return
|
|
32
|
+
this.callbacks.push(callback)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
start() {
|
|
36
|
+
if (this.running) return
|
|
37
|
+
this._state = 'running'
|
|
38
|
+
this.lastTickTime = performance.now()
|
|
39
|
+
this._accumulator = 0
|
|
40
|
+
// Fixed-timestep scheduling: a single setInterval at roughly half the tick
|
|
41
|
+
// duration drives the loop; each firing consumes as many whole ticks as have
|
|
42
|
+
// accumulated (accumulator-based catch-up), instead of a setTimeout(...,1)/
|
|
43
|
+
// setImmediate busy-loop that drifts and burns CPU re-scheduling every ~1ms.
|
|
44
|
+
const intervalMs = Math.max(1, this.tickDuration / 2)
|
|
45
|
+
this._intervalHandle = setInterval(() => this._onInterval(), intervalMs)
|
|
46
|
+
if (this._intervalHandle.unref) this._intervalHandle.unref()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
_onInterval() {
|
|
50
|
+
if (!this.running) return
|
|
51
|
+
const now = performance.now()
|
|
52
|
+
this._accumulator += now - this.lastTickTime
|
|
53
|
+
this.lastTickTime = now
|
|
54
|
+
const maxSteps = 4
|
|
55
|
+
// Cap the catch-up burst so a long stall (debugger pause, GC, reload) doesn't
|
|
56
|
+
// try to replay an unbounded backlog of ticks in one go.
|
|
57
|
+
const maxAccumulated = this.tickDuration * maxSteps
|
|
58
|
+
if (this._accumulator > maxAccumulated) this._accumulator = maxAccumulated
|
|
59
|
+
let steps = 0
|
|
60
|
+
const isPaused = this._state === 'paused'
|
|
61
|
+
while (this._accumulator >= this.tickDuration && !isPaused && steps < maxSteps) {
|
|
62
|
+
const dt = this._computeDt()
|
|
63
|
+
this._tickInProgress = true
|
|
64
|
+
this.currentTick++
|
|
65
|
+
this._accumulator -= this.tickDuration
|
|
66
|
+
const t0 = performance.now()
|
|
67
|
+
for (const callback of this.callbacks) {
|
|
68
|
+
// a throwing callback must not abort the loop / wedge pauseForReload's _tickInProgress
|
|
69
|
+
try {
|
|
70
|
+
callback(this.currentTick, dt)
|
|
71
|
+
} catch (e) {
|
|
72
|
+
console.error(this._tickErrorTag, e?.stack || e?.message || e)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
this._onTickMeasured(performance.now() - t0)
|
|
76
|
+
this._tickInProgress = false
|
|
77
|
+
if (this._reloadResolve) {
|
|
78
|
+
this._reloadResolve()
|
|
79
|
+
this._reloadResolve = null
|
|
80
|
+
}
|
|
81
|
+
steps++
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
pauseForReload() {
|
|
86
|
+
this._state = 'paused'
|
|
87
|
+
if (!this._tickInProgress) return Promise.resolve()
|
|
88
|
+
return new Promise(resolve => { this._reloadResolve = resolve })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
resumeAfterReload() {
|
|
92
|
+
this._state = 'running'
|
|
93
|
+
this.lastTickTime = performance.now()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
stop() {
|
|
97
|
+
this._state = 'stopped'
|
|
98
|
+
if (this._intervalHandle) {
|
|
99
|
+
clearInterval(this._intervalHandle)
|
|
100
|
+
this._intervalHandle = null
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
getTick() {
|
|
105
|
+
return this.currentTick
|
|
106
|
+
}
|
|
107
|
+
}
|