@jts-studios/web-game-engine 0.1.0
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/CHANGELOG.md +13 -0
- package/FALLBACK_STRATEGY.md +21 -0
- package/PLUGIN_API.md +34 -0
- package/README.md +49 -0
- package/package.json +37 -0
- package/src/adapters/runtime/GameRuntime.js +446 -0
- package/src/app/GameApp.js +6 -0
- package/src/app/camera/CameraController.js +1 -0
- package/src/app/input/InputController.js +1 -0
- package/src/app/input/InputProfileCatalog.js +1 -0
- package/src/app/loop/UpdateLoop.js +1 -0
- package/src/app/state/SceneManager.js +1 -0
- package/src/app/state/SceneSnapshotStore.js +1 -0
- package/src/core/camera/CameraController.js +119 -0
- package/src/core/input/InputController.js +207 -0
- package/src/core/input/InputProfileCatalog.js +29 -0
- package/src/core/loop/FixedStepClock.js +30 -0
- package/src/core/loop/InputReplayTimeline.js +155 -0
- package/src/core/loop/InputReplayTimeline.test.js +43 -0
- package/src/core/loop/UpdateLoop.js +28 -0
- package/src/core/runtime/RuntimeBridge.js +54 -0
- package/src/core/runtime/RuntimeBridge.test.js +25 -0
- package/src/core/state/AssetManifestLoader.js +83 -0
- package/src/core/state/AssetManifestLoader.test.js +59 -0
- package/src/core/state/SceneManager.js +60 -0
- package/src/core/state/SceneSnapshotStore.js +98 -0
- package/src/index.d.ts +27 -0
- package/src/index.js +11 -0
- package/src/main.js +213 -0
- package/src/scenes/createBootstrapScene.js +17 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
export class CameraController {
|
|
2
|
+
constructor(camera, input, { moveSpeed = 500, zoomSpeed = 0.0015, minZoom = 0.15, maxZoom = 4 } = {}) {
|
|
3
|
+
this.camera = camera
|
|
4
|
+
this.input = input
|
|
5
|
+
this.moveSpeed = moveSpeed
|
|
6
|
+
this.zoomSpeed = zoomSpeed
|
|
7
|
+
this.minZoom = minZoom
|
|
8
|
+
this.maxZoom = maxZoom
|
|
9
|
+
this.followTarget = null
|
|
10
|
+
this.followSmoothing = 0.16
|
|
11
|
+
this.deadZone = { x: 36, y: 24 }
|
|
12
|
+
this.bounds = null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
setFollowTarget(target) {
|
|
16
|
+
this.followTarget = target ?? null
|
|
17
|
+
return this
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
setFollowSmoothing(value) {
|
|
21
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
22
|
+
throw new TypeError("CameraController follow smoothing must be a positive number")
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
this.followSmoothing = value
|
|
26
|
+
return this
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
setDeadZone(width, height) {
|
|
30
|
+
if (!Number.isFinite(width) || width < 0 || !Number.isFinite(height) || height < 0) {
|
|
31
|
+
throw new TypeError("CameraController dead-zone dimensions must be non-negative numbers")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
this.deadZone.x = width
|
|
35
|
+
this.deadZone.y = height
|
|
36
|
+
return this
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
setBounds(bounds = null) {
|
|
40
|
+
if (!bounds) {
|
|
41
|
+
this.bounds = null
|
|
42
|
+
return this
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
this.bounds = {
|
|
46
|
+
minX: Number.isFinite(bounds.minX) ? bounds.minX : -Infinity,
|
|
47
|
+
minY: Number.isFinite(bounds.minY) ? bounds.minY : -Infinity,
|
|
48
|
+
maxX: Number.isFinite(bounds.maxX) ? bounds.maxX : Infinity,
|
|
49
|
+
maxY: Number.isFinite(bounds.maxY) ? bounds.maxY : Infinity,
|
|
50
|
+
}
|
|
51
|
+
return this
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
update(deltaTime) {
|
|
55
|
+
const moveX = this.input.getAxis("KeyA", "KeyD") + this.input.getAxis("ArrowLeft", "ArrowRight")
|
|
56
|
+
const moveY = this.input.getAxis("KeyW", "KeyS") + this.input.getAxis("ArrowUp", "ArrowDown")
|
|
57
|
+
|
|
58
|
+
if (moveX !== 0 || moveY !== 0) {
|
|
59
|
+
const distance = (this.moveSpeed * deltaTime) / this.camera.zoom
|
|
60
|
+
this.camera.setPosition(this.camera.position.x + moveX * distance, this.camera.position.y + moveY * distance)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (this.input.isPointerDragging()) {
|
|
64
|
+
const pointerDelta = this.input.consumePointerDelta()
|
|
65
|
+
this.camera.setPosition(
|
|
66
|
+
this.camera.position.x - pointerDelta.x / this.camera.zoom,
|
|
67
|
+
this.camera.position.y - pointerDelta.y / this.camera.zoom,
|
|
68
|
+
)
|
|
69
|
+
} else {
|
|
70
|
+
this.input.consumePointerDelta()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const wheelDelta = this.input.consumeWheelDelta()
|
|
74
|
+
if (wheelDelta !== 0) {
|
|
75
|
+
const targetZoom = this.camera.zoom * Math.exp(-wheelDelta * this.zoomSpeed)
|
|
76
|
+
const clampedZoom = Math.min(this.maxZoom, Math.max(this.minZoom, targetZoom))
|
|
77
|
+
this.camera.setZoom(clampedZoom)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
this.#updateFollow(deltaTime)
|
|
81
|
+
this.#clampToBounds()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
#updateFollow(deltaTime) {
|
|
85
|
+
const targetPosition = this.followTarget?.position
|
|
86
|
+
if (!targetPosition) {
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const dx = targetPosition.x - this.camera.position.x
|
|
91
|
+
const dy = targetPosition.y - this.camera.position.y
|
|
92
|
+
|
|
93
|
+
const thresholdX = this.deadZone.x / this.camera.zoom
|
|
94
|
+
const thresholdY = this.deadZone.y / this.camera.zoom
|
|
95
|
+
|
|
96
|
+
const shouldMoveX = Math.abs(dx) > thresholdX
|
|
97
|
+
const shouldMoveY = Math.abs(dy) > thresholdY
|
|
98
|
+
if (!shouldMoveX && !shouldMoveY) {
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const smoothing = 1 - Math.exp((-deltaTime * 60) / this.followSmoothing)
|
|
103
|
+
const nextX = shouldMoveX ? this.camera.position.x + dx * smoothing : this.camera.position.x
|
|
104
|
+
const nextY = shouldMoveY ? this.camera.position.y + dy * smoothing : this.camera.position.y
|
|
105
|
+
this.camera.setPosition(nextX, nextY)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
#clampToBounds() {
|
|
109
|
+
if (!this.bounds) {
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const clampedX = Math.min(this.bounds.maxX, Math.max(this.bounds.minX, this.camera.position.x))
|
|
114
|
+
const clampedY = Math.min(this.bounds.maxY, Math.max(this.bounds.minY, this.camera.position.y))
|
|
115
|
+
if (clampedX !== this.camera.position.x || clampedY !== this.camera.position.y) {
|
|
116
|
+
this.camera.setPosition(clampedX, clampedY)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
export class InputController {
|
|
2
|
+
constructor({ element = null } = {}) {
|
|
3
|
+
this.element = element
|
|
4
|
+
this._keys = new Set()
|
|
5
|
+
this._wheelDelta = 0
|
|
6
|
+
this._dragging = false
|
|
7
|
+
this._pointerDeltaX = 0
|
|
8
|
+
this._pointerDeltaY = 0
|
|
9
|
+
this._lastPointerX = 0
|
|
10
|
+
this._lastPointerY = 0
|
|
11
|
+
this._actionProfile = null
|
|
12
|
+
|
|
13
|
+
this._onKeyDown = this.#onKeyDown.bind(this)
|
|
14
|
+
this._onKeyUp = this.#onKeyUp.bind(this)
|
|
15
|
+
this._onBlur = this.#onBlur.bind(this)
|
|
16
|
+
this._onWheel = this.#onWheel.bind(this)
|
|
17
|
+
this._onPointerDown = this.#onPointerDown.bind(this)
|
|
18
|
+
this._onPointerMove = this.#onPointerMove.bind(this)
|
|
19
|
+
this._onPointerUp = this.#onPointerUp.bind(this)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
attach() {
|
|
23
|
+
window.addEventListener("keydown", this._onKeyDown)
|
|
24
|
+
window.addEventListener("keyup", this._onKeyUp)
|
|
25
|
+
window.addEventListener("blur", this._onBlur)
|
|
26
|
+
window.addEventListener("wheel", this._onWheel, { passive: false })
|
|
27
|
+
|
|
28
|
+
const target = this.element ?? window
|
|
29
|
+
target.addEventListener("pointerdown", this._onPointerDown)
|
|
30
|
+
window.addEventListener("pointermove", this._onPointerMove)
|
|
31
|
+
window.addEventListener("pointerup", this._onPointerUp)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
detach() {
|
|
35
|
+
window.removeEventListener("keydown", this._onKeyDown)
|
|
36
|
+
window.removeEventListener("keyup", this._onKeyUp)
|
|
37
|
+
window.removeEventListener("blur", this._onBlur)
|
|
38
|
+
window.removeEventListener("wheel", this._onWheel)
|
|
39
|
+
|
|
40
|
+
const target = this.element ?? window
|
|
41
|
+
target.removeEventListener("pointerdown", this._onPointerDown)
|
|
42
|
+
window.removeEventListener("pointermove", this._onPointerMove)
|
|
43
|
+
window.removeEventListener("pointerup", this._onPointerUp)
|
|
44
|
+
|
|
45
|
+
this._keys.clear()
|
|
46
|
+
this._wheelDelta = 0
|
|
47
|
+
this._dragging = false
|
|
48
|
+
this._pointerDeltaX = 0
|
|
49
|
+
this._pointerDeltaY = 0
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
isKeyDown(code) {
|
|
53
|
+
return this._keys.has(code)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
getAxis(negativeCode, positiveCode) {
|
|
57
|
+
const negative = this.isKeyDown(negativeCode) ? -1 : 0
|
|
58
|
+
const positive = this.isKeyDown(positiveCode) ? 1 : 0
|
|
59
|
+
return negative + positive
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
consumeWheelDelta() {
|
|
63
|
+
const delta = this._wheelDelta
|
|
64
|
+
this._wheelDelta = 0
|
|
65
|
+
return delta
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
consumePointerDelta() {
|
|
69
|
+
const deltaX = this._pointerDeltaX
|
|
70
|
+
const deltaY = this._pointerDeltaY
|
|
71
|
+
this._pointerDeltaX = 0
|
|
72
|
+
this._pointerDeltaY = 0
|
|
73
|
+
return { x: deltaX, y: deltaY }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
isPointerDragging() {
|
|
77
|
+
return this._dragging
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
captureSnapshot() {
|
|
81
|
+
return {
|
|
82
|
+
keys: Array.from(this._keys),
|
|
83
|
+
pointerX: this._pointerX,
|
|
84
|
+
pointerY: this._pointerY,
|
|
85
|
+
pointerDeltaX: this._pointerDeltaX,
|
|
86
|
+
pointerDeltaY: this._pointerDeltaY,
|
|
87
|
+
dragging: this._dragging,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
applySnapshot(snapshot) {
|
|
92
|
+
if (!snapshot || typeof snapshot !== "object") {
|
|
93
|
+
return this
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
this._keys.clear()
|
|
97
|
+
const keys = Array.isArray(snapshot.keys) ? snapshot.keys : []
|
|
98
|
+
for (let i = 0; i < keys.length; i++) {
|
|
99
|
+
if (typeof keys[i] === "string") {
|
|
100
|
+
this._keys.add(keys[i])
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
this._pointerX = Number.isFinite(snapshot.pointerX) ? snapshot.pointerX : this._pointerX
|
|
105
|
+
this._pointerY = Number.isFinite(snapshot.pointerY) ? snapshot.pointerY : this._pointerY
|
|
106
|
+
this._pointerDeltaX = Number.isFinite(snapshot.pointerDeltaX) ? snapshot.pointerDeltaX : this._pointerDeltaX
|
|
107
|
+
this._pointerDeltaY = Number.isFinite(snapshot.pointerDeltaY) ? snapshot.pointerDeltaY : this._pointerDeltaY
|
|
108
|
+
this._dragging = !!snapshot.dragging
|
|
109
|
+
return this
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
applyProfile(profile) {
|
|
113
|
+
this._actionProfile = profile && typeof profile === "object" ? JSON.parse(JSON.stringify(profile)) : null
|
|
114
|
+
return this
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
clearProfile() {
|
|
118
|
+
this._actionProfile = null
|
|
119
|
+
return this
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
isActionDown(actionName) {
|
|
123
|
+
if (!this._actionProfile || typeof actionName !== "string") {
|
|
124
|
+
return false
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const bindings = this._actionProfile[actionName]
|
|
128
|
+
if (!Array.isArray(bindings)) {
|
|
129
|
+
return false
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (let i = 0; i < bindings.length; i++) {
|
|
133
|
+
const binding = bindings[i]
|
|
134
|
+
if (!binding || typeof binding !== "object") {
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (binding.type === "keyboard") {
|
|
139
|
+
if (Array.isArray(binding.chord) && binding.chord.length > 0) {
|
|
140
|
+
let chordPressed = true
|
|
141
|
+
for (let c = 0; c < binding.chord.length; c++) {
|
|
142
|
+
if (!this.isKeyDown(binding.chord[c])) {
|
|
143
|
+
chordPressed = false
|
|
144
|
+
break
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (chordPressed) {
|
|
148
|
+
return true
|
|
149
|
+
}
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (typeof binding.code === "string" && this.isKeyDown(binding.code)) {
|
|
154
|
+
return true
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return false
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
#onKeyDown(event) {
|
|
163
|
+
this._keys.add(event.code)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
#onKeyUp(event) {
|
|
167
|
+
this._keys.delete(event.code)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
#onBlur() {
|
|
171
|
+
this._keys.clear()
|
|
172
|
+
this._dragging = false
|
|
173
|
+
this._wheelDelta = 0
|
|
174
|
+
this._pointerDeltaX = 0
|
|
175
|
+
this._pointerDeltaY = 0
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
#onWheel(event) {
|
|
179
|
+
this._wheelDelta += event.deltaY
|
|
180
|
+
event.preventDefault()
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
#onPointerDown(event) {
|
|
184
|
+
if (event.button !== 1 && event.button !== 0) {
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
this._dragging = true
|
|
189
|
+
this._lastPointerX = event.clientX
|
|
190
|
+
this._lastPointerY = event.clientY
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
#onPointerMove(event) {
|
|
194
|
+
if (!this._dragging) {
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
this._pointerDeltaX += event.clientX - this._lastPointerX
|
|
199
|
+
this._pointerDeltaY += event.clientY - this._lastPointerY
|
|
200
|
+
this._lastPointerX = event.clientX
|
|
201
|
+
this._lastPointerY = event.clientY
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
#onPointerUp() {
|
|
205
|
+
this._dragging = false
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stores named input action profiles for gameplay integration.
|
|
3
|
+
*/
|
|
4
|
+
export class InputProfileCatalog {
|
|
5
|
+
constructor() {
|
|
6
|
+
this._profiles = new Map()
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
define(name, profile) {
|
|
10
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
11
|
+
throw new TypeError("InputProfileCatalog.define requires a non-empty profile name")
|
|
12
|
+
}
|
|
13
|
+
if (!profile || typeof profile !== "object") {
|
|
14
|
+
throw new TypeError("InputProfileCatalog.define requires a profile object")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
this._profiles.set(name, JSON.parse(JSON.stringify(profile)))
|
|
18
|
+
return this
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get(name) {
|
|
22
|
+
const profile = this._profiles.get(name)
|
|
23
|
+
return profile ? JSON.parse(JSON.stringify(profile)) : null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
list() {
|
|
27
|
+
return Array.from(this._profiles.keys())
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export class FixedStepClock {
|
|
2
|
+
constructor({ fixedDelta = 1 / 60, maxSubSteps = 8 } = {}) {
|
|
3
|
+
this.fixedDelta = Number.isFinite(fixedDelta) && fixedDelta > 0 ? fixedDelta : 1 / 60
|
|
4
|
+
this.maxSubSteps = Number.isFinite(maxSubSteps) ? Math.max(1, Math.floor(maxSubSteps)) : 8
|
|
5
|
+
this.accumulator = 0
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
reset() {
|
|
9
|
+
this.accumulator = 0
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
consume(deltaSeconds) {
|
|
13
|
+
const safeDelta = Number.isFinite(deltaSeconds) ? Math.max(0, deltaSeconds) : 0
|
|
14
|
+
this.accumulator += safeDelta
|
|
15
|
+
|
|
16
|
+
const steps = []
|
|
17
|
+
let subSteps = 0
|
|
18
|
+
while (this.accumulator >= this.fixedDelta && subSteps < this.maxSubSteps) {
|
|
19
|
+
steps.push(this.fixedDelta)
|
|
20
|
+
this.accumulator -= this.fixedDelta
|
|
21
|
+
subSteps += 1
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const alpha = this.fixedDelta > 0 ? Math.min(1, this.accumulator / this.fixedDelta) : 0
|
|
25
|
+
return {
|
|
26
|
+
steps,
|
|
27
|
+
alpha,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
export class InputReplayTimeline {
|
|
2
|
+
constructor() {
|
|
3
|
+
this._events = []
|
|
4
|
+
this._recording = false
|
|
5
|
+
this._playing = false
|
|
6
|
+
this._cursor = 0
|
|
7
|
+
this._elapsed = 0
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
clear() {
|
|
11
|
+
this._events.length = 0
|
|
12
|
+
this._cursor = 0
|
|
13
|
+
this._elapsed = 0
|
|
14
|
+
return this
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
startRecording() {
|
|
18
|
+
this.clear()
|
|
19
|
+
this._recording = true
|
|
20
|
+
this._playing = false
|
|
21
|
+
return this
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
stopRecording() {
|
|
25
|
+
this._recording = false
|
|
26
|
+
return this.toJSON()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
startPlayback(events = this._events) {
|
|
30
|
+
this._events = Array.isArray(events) ? events.map((entry) => ({ ...entry })) : []
|
|
31
|
+
this._events.sort((a, b) => (a.time ?? 0) - (b.time ?? 0))
|
|
32
|
+
this._recording = false
|
|
33
|
+
this._playing = true
|
|
34
|
+
this._cursor = 0
|
|
35
|
+
this._elapsed = 0
|
|
36
|
+
return this
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
stopPlayback() {
|
|
40
|
+
this._playing = false
|
|
41
|
+
this._cursor = 0
|
|
42
|
+
this._elapsed = 0
|
|
43
|
+
return this
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
record(deltaTime, snapshot, { checkpoint = false } = {}) {
|
|
47
|
+
if (!this._recording) {
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
this._elapsed += Number.isFinite(deltaTime) ? Math.max(0, deltaTime) : 0
|
|
52
|
+
const clonedSnapshot = snapshot && typeof snapshot === "object" ? JSON.parse(JSON.stringify(snapshot)) : null
|
|
53
|
+
this._events.push({
|
|
54
|
+
time: this._elapsed,
|
|
55
|
+
snapshot: clonedSnapshot,
|
|
56
|
+
hash: this.#hashSnapshot(clonedSnapshot),
|
|
57
|
+
checkpoint: !!checkpoint,
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
consume(deltaTime) {
|
|
62
|
+
if (!this._playing) {
|
|
63
|
+
return []
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
this._elapsed += Number.isFinite(deltaTime) ? Math.max(0, deltaTime) : 0
|
|
67
|
+
const emitted = []
|
|
68
|
+
while (this._cursor < this._events.length) {
|
|
69
|
+
const event = this._events[this._cursor]
|
|
70
|
+
if ((event?.time ?? 0) > this._elapsed) {
|
|
71
|
+
break
|
|
72
|
+
}
|
|
73
|
+
emitted.push(event)
|
|
74
|
+
this._cursor += 1
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (this._cursor >= this._events.length) {
|
|
78
|
+
this._playing = false
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return emitted
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
toJSON() {
|
|
85
|
+
return this._events.map((entry) => ({
|
|
86
|
+
time: entry.time,
|
|
87
|
+
snapshot: entry.snapshot,
|
|
88
|
+
hash: entry.hash,
|
|
89
|
+
checkpoint: !!entry.checkpoint,
|
|
90
|
+
}))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
validate(events = this._events) {
|
|
94
|
+
const list = Array.isArray(events) ? events : []
|
|
95
|
+
for (let i = 0; i < list.length; i++) {
|
|
96
|
+
const entry = list[i]
|
|
97
|
+
const expected = this.#hashSnapshot(entry?.snapshot ?? null)
|
|
98
|
+
if (typeof entry?.hash === "string" && entry.hash !== expected) {
|
|
99
|
+
return {
|
|
100
|
+
valid: false,
|
|
101
|
+
index: i,
|
|
102
|
+
expected,
|
|
103
|
+
actual: entry.hash,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
valid: true,
|
|
110
|
+
count: list.length,
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
compareWith(otherEvents) {
|
|
115
|
+
const a = this.toJSON()
|
|
116
|
+
const b = Array.isArray(otherEvents) ? otherEvents : []
|
|
117
|
+
const max = Math.max(a.length, b.length)
|
|
118
|
+
for (let i = 0; i < max; i++) {
|
|
119
|
+
const left = a[i]
|
|
120
|
+
const right = b[i]
|
|
121
|
+
if (!left || !right) {
|
|
122
|
+
return {
|
|
123
|
+
equal: false,
|
|
124
|
+
index: i,
|
|
125
|
+
reason: "length-mismatch",
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (left.hash !== right.hash) {
|
|
130
|
+
return {
|
|
131
|
+
equal: false,
|
|
132
|
+
index: i,
|
|
133
|
+
reason: "hash-mismatch",
|
|
134
|
+
left: left.hash,
|
|
135
|
+
right: right.hash,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
equal: true,
|
|
142
|
+
count: a.length,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#hashSnapshot(snapshot) {
|
|
147
|
+
const input = JSON.stringify(snapshot ?? null)
|
|
148
|
+
let hash = 2166136261
|
|
149
|
+
for (let i = 0; i < input.length; i++) {
|
|
150
|
+
hash ^= input.charCodeAt(i)
|
|
151
|
+
hash = Math.imul(hash, 16777619)
|
|
152
|
+
}
|
|
153
|
+
return `fnv1a-${(hash >>> 0).toString(16)}`
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import test from "node:test"
|
|
2
|
+
import assert from "node:assert/strict"
|
|
3
|
+
|
|
4
|
+
import { InputReplayTimeline } from "./InputReplayTimeline.js"
|
|
5
|
+
|
|
6
|
+
test("InputReplayTimeline records hashes and validates deterministic payloads", () => {
|
|
7
|
+
const replay = new InputReplayTimeline()
|
|
8
|
+
replay.startRecording()
|
|
9
|
+
replay.record(1 / 60, { actions: { jump: true } }, { checkpoint: true })
|
|
10
|
+
const events = replay.stopRecording()
|
|
11
|
+
|
|
12
|
+
assert.equal(events.length, 1)
|
|
13
|
+
assert.equal(typeof events[0].hash, "string")
|
|
14
|
+
assert.equal(events[0].hash.startsWith("fnv1a-"), true)
|
|
15
|
+
assert.equal(events[0].checkpoint, true)
|
|
16
|
+
|
|
17
|
+
const diagnostics = replay.validate(events)
|
|
18
|
+
assert.equal(diagnostics.valid, true)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test("InputReplayTimeline validate detects tampered snapshots", () => {
|
|
22
|
+
const replay = new InputReplayTimeline()
|
|
23
|
+
replay.startRecording()
|
|
24
|
+
replay.record(1 / 60, { actions: { fire: false } })
|
|
25
|
+
const events = replay.stopRecording()
|
|
26
|
+
events[0].snapshot.actions.fire = true
|
|
27
|
+
|
|
28
|
+
const diagnostics = replay.validate(events)
|
|
29
|
+
assert.equal(diagnostics.valid, false)
|
|
30
|
+
assert.equal(diagnostics.index, 0)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test("InputReplayTimeline compareWith detects divergence", () => {
|
|
34
|
+
const replay = new InputReplayTimeline()
|
|
35
|
+
replay.startRecording()
|
|
36
|
+
replay.record(1 / 60, { actions: { left: true } })
|
|
37
|
+
const baseline = replay.stopRecording()
|
|
38
|
+
|
|
39
|
+
const modified = baseline.map((entry) => ({ ...entry, hash: `${entry.hash}-changed` }))
|
|
40
|
+
const comparison = replay.compareWith(modified)
|
|
41
|
+
assert.equal(comparison.equal, false)
|
|
42
|
+
assert.equal(comparison.reason, "hash-mismatch")
|
|
43
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export class UpdateLoop {
|
|
2
|
+
constructor() {
|
|
3
|
+
this._updatables = new Set()
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
add(updatable) {
|
|
7
|
+
if (!updatable || typeof updatable.update !== "function") {
|
|
8
|
+
throw new TypeError("UpdateLoop.add requires an object with update(deltaTime)")
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
this._updatables.add(updatable)
|
|
12
|
+
return updatable
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
remove(updatable) {
|
|
16
|
+
this._updatables.delete(updatable)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
clear() {
|
|
20
|
+
this._updatables.clear()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
update(deltaTime) {
|
|
24
|
+
for (const updatable of this._updatables) {
|
|
25
|
+
updatable.update(deltaTime)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export class RuntimeBridge {
|
|
2
|
+
constructor() {
|
|
3
|
+
this._eventListeners = new Set()
|
|
4
|
+
this._commandHandlers = new Map()
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
onEvent(listener) {
|
|
8
|
+
if (typeof listener !== "function") {
|
|
9
|
+
return () => {}
|
|
10
|
+
}
|
|
11
|
+
this._eventListeners.add(listener)
|
|
12
|
+
return () => this._eventListeners.delete(listener)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
emit(type, payload = null) {
|
|
16
|
+
if (typeof type !== "string" || type.length === 0) {
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const event = {
|
|
21
|
+
type,
|
|
22
|
+
payload,
|
|
23
|
+
time: Date.now(),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
for (const listener of this._eventListeners) {
|
|
27
|
+
try {
|
|
28
|
+
listener(event)
|
|
29
|
+
} catch {
|
|
30
|
+
// Bridge listeners are isolated from runtime execution.
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
registerCommand(commandName, handler) {
|
|
36
|
+
if (typeof commandName !== "string" || commandName.length === 0) {
|
|
37
|
+
throw new TypeError("RuntimeBridge.registerCommand requires a non-empty commandName")
|
|
38
|
+
}
|
|
39
|
+
if (typeof handler !== "function") {
|
|
40
|
+
throw new TypeError("RuntimeBridge.registerCommand requires a handler function")
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
this._commandHandlers.set(commandName, handler)
|
|
44
|
+
return this
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async execute(commandName, payload = null, context = null) {
|
|
48
|
+
const handler = this._commandHandlers.get(commandName)
|
|
49
|
+
if (!handler) {
|
|
50
|
+
throw new Error(`RuntimeBridge.execute unknown command '${commandName}'`)
|
|
51
|
+
}
|
|
52
|
+
return await handler(payload, context)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import test from "node:test"
|
|
2
|
+
import assert from "node:assert/strict"
|
|
3
|
+
|
|
4
|
+
import { RuntimeBridge } from "./RuntimeBridge.js"
|
|
5
|
+
|
|
6
|
+
test("RuntimeBridge emits events to listeners", () => {
|
|
7
|
+
const bridge = new RuntimeBridge()
|
|
8
|
+
const events = []
|
|
9
|
+
const off = bridge.onEvent((event) => events.push(event))
|
|
10
|
+
|
|
11
|
+
bridge.emit("runtime:init", { ok: true })
|
|
12
|
+
off()
|
|
13
|
+
bridge.emit("runtime:update", { dt: 0.016 })
|
|
14
|
+
|
|
15
|
+
assert.equal(events.length, 1)
|
|
16
|
+
assert.equal(events[0].type, "runtime:init")
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test("RuntimeBridge executes registered commands", async () => {
|
|
20
|
+
const bridge = new RuntimeBridge()
|
|
21
|
+
bridge.registerCommand("echo", async (payload) => payload)
|
|
22
|
+
|
|
23
|
+
const response = await bridge.execute("echo", { value: 42 })
|
|
24
|
+
assert.deepEqual(response, { value: 42 })
|
|
25
|
+
})
|