@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 ADDED
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ ## [0.1.0] - 2026-03-10
6
+
7
+ ### Added
8
+
9
+ - First public package release for `@jts-studios/web-game-engine`.
10
+ - Runtime loop foundation with `FixedStepClock` and `InputReplayTimeline`.
11
+ - Plugin lifecycle support in `GameRuntime` with capability/dependency checks.
12
+ - Scene snapshot utilities and asset preloading with `SceneSnapshotStore` and `AssetManifestLoader`.
13
+ - Export verification and package verify script for release quality gates.
@@ -0,0 +1,21 @@
1
+ # WebGPU Fallback Strategy
2
+
3
+ `@jts-studios/web-game-engine` targets `@jts-studios/engine` WebGPU rendering.
4
+
5
+ ## Runtime Policy
6
+
7
+ - Default policy: `warn`.
8
+ - `strict`: throw when WebGPU is unavailable.
9
+ - `warn`: enter degraded mode and emit diagnostics.
10
+ - `silent`: enter degraded mode without warnings.
11
+
12
+ ## Degraded Mode
13
+
14
+ When WebGPU is unavailable, gameplay systems continue updating while rendering is skipped.
15
+ Use `runtime.getFallbackStatus()` to detect this mode.
16
+
17
+ ## Recommended App Behavior
18
+
19
+ - Show a clear UI message in degraded mode.
20
+ - Offer browser/device upgrade guidance.
21
+ - Optionally expose a non-WebGPU renderer in the host app.
package/PLUGIN_API.md ADDED
@@ -0,0 +1,34 @@
1
+ # Plugin API
2
+
3
+ `GameRuntime.use(plugin)` accepts plugin objects that implement optional lifecycle hooks.
4
+
5
+ ## Required Shape
6
+
7
+ - `name: string` recommended for diagnostics and dependency wiring.
8
+ - `apiVersion?: number` defaults to runtime API version.
9
+ - `capabilities?: string[]` any of: `render-hooks`, `update-hooks`, `scene-state`, `input-profile`.
10
+ - `dependencies?: string[]` plugin names that must be registered first.
11
+ - `priority?: number` lower values run first.
12
+ - `enabled?: boolean` defaults to `true`.
13
+
14
+ ## Lifecycle Hooks
15
+
16
+ - `onInit(runtime)` called once after runtime init.
17
+ - `onUpdate(runtime, deltaTime)` called per fixed-step update.
18
+ - `onRender(runtime, alpha)` called after render.
19
+ - `onDestroy(runtime)` called during teardown.
20
+
21
+ ## Error Handling
22
+
23
+ Hook exceptions are isolated and routed through `runtime.onPluginError(handler)`.
24
+ Handlers receive:
25
+
26
+ - `plugin`: plugin name
27
+ - `hookName`: lifecycle hook identifier
28
+ - `error`: original exception
29
+
30
+ ## Compatibility Guidance
31
+
32
+ - Keep plugin behavior deterministic for replay-heavy projects.
33
+ - Declare `dependencies` and `capabilities` explicitly.
34
+ - Avoid mutating runtime internals not exposed by public APIs.
package/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # web-game-engine
2
+
3
+ This package is a core game-framework layer built on top of `@jts-studios/engine`.
4
+
5
+ `@jts-studios/engine` remains the rendering engine and GPU/rendering foundation.
6
+
7
+ `@jts-studios/web-game-engine` provides reusable gameplay/runtime systems that compose that renderer.
8
+
9
+ ## Structure
10
+
11
+ - `src/core/*`: reusable library systems (input, camera, state, loop).
12
+ - `src/adapters/*`: integration glue to concrete runtimes (for example, browser runtime adapter).
13
+ - `src/index.js`: public library entrypoint.
14
+ - `src/app/*`: compatibility wrappers for older import paths.
15
+
16
+ ## Included Game Systems
17
+
18
+ - Follow-camera controller with smoothing and dead-zone behavior.
19
+ - Optional world-bounds clamping for camera movement.
20
+ - Scene/state loop scaffolding intended for game-level orchestration.
21
+ - Snapshot store and input profile catalog for runtime workflows.
22
+
23
+ ## Public API
24
+
25
+ - `GameRuntime`
26
+ - `CameraController`
27
+ - `InputController`
28
+ - `InputProfileCatalog`
29
+ - `UpdateLoop`
30
+ - `FixedStepClock`
31
+ - `InputReplayTimeline`
32
+ - `RuntimeBridge`
33
+ - `SceneManager`
34
+ - `SceneSnapshotStore`
35
+ - `AssetManifestLoader`
36
+
37
+ ## Commands
38
+
39
+ - `npm install`
40
+ - `npm run dev`
41
+ - `npm run build`
42
+ - `npm run verify`
43
+ - `npm run release:verify`
44
+
45
+ ## Separation Rule
46
+
47
+ - Put rendering primitives and GPU systems in `web-engine`.
48
+ - Put reusable gameplay/entity/physics/combat/quest systems in `web-game-engine`.
49
+ - Put domain-specific implementations (for example, chess rules and chess assets) in their own sibling project folders, such as `chess`.
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@jts-studios/web-game-engine",
3
+ "private": false,
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "module": "./src/index.js",
8
+ "types": "./src/index.d.ts",
9
+ "exports": {
10
+ ".": "./src/index.js"
11
+ },
12
+ "files": [
13
+ "src",
14
+ "README.md",
15
+ "CHANGELOG.md",
16
+ "PLUGIN_API.md",
17
+ "FALLBACK_STRATEGY.md"
18
+ ],
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "scripts": {
23
+ "dev": "vite",
24
+ "build": "vite build",
25
+ "preview": "vite preview",
26
+ "test": "node --test \"src/**/*.test.js\"",
27
+ "check:exports": "node scripts/check-exports.mjs",
28
+ "verify": "npm run check:exports && npm run test && npm run build",
29
+ "release:verify": "npm run verify && npm pack --dry-run"
30
+ },
31
+ "dependencies": {
32
+ "@jts-studios/engine": "^1.5.0"
33
+ },
34
+ "devDependencies": {
35
+ "vite": "^7.3.1"
36
+ }
37
+ }
@@ -0,0 +1,446 @@
1
+ import { Camera, ColorRGBA, Scene, WebGPURenderer2D } from "@jts-studios/engine"
2
+ import { InputController } from "../../core/input/InputController.js"
3
+ import { InputProfileCatalog } from "../../core/input/InputProfileCatalog.js"
4
+ import { CameraController } from "../../core/camera/CameraController.js"
5
+ import { UpdateLoop } from "../../core/loop/UpdateLoop.js"
6
+ import { FixedStepClock } from "../../core/loop/FixedStepClock.js"
7
+ import { InputReplayTimeline } from "../../core/loop/InputReplayTimeline.js"
8
+ import { SceneManager } from "../../core/state/SceneManager.js"
9
+ import { SceneSnapshotStore } from "../../core/state/SceneSnapshotStore.js"
10
+ import { AssetManifestLoader } from "../../core/state/AssetManifestLoader.js"
11
+ import { RuntimeBridge } from "../../core/runtime/RuntimeBridge.js"
12
+
13
+ /**
14
+ * Runtime adapter that wires web-engine rendering with web-game-engine core systems.
15
+ */
16
+ export class GameRuntime {
17
+ constructor({ canvas, fixedDelta = 1 / 60, maxSubSteps = 8, pluginApiVersion = 1, fallbackPolicy = "warn" } = {}) {
18
+ this.canvas = canvas
19
+ this.renderer = new WebGPURenderer2D({
20
+ canvas,
21
+ clearColor: new ColorRGBA(0.04, 0.06, 0.1, 1),
22
+ })
23
+ this.camera = new Camera({
24
+ position: { x: 0, y: 0 },
25
+ zoom: 1,
26
+ })
27
+ this.scene = new Scene({
28
+ backgroundColor: new ColorRGBA(0.04, 0.06, 0.1, 1),
29
+ })
30
+ this.sceneManager = new SceneManager()
31
+ this.sceneManager.setScene(this.scene)
32
+
33
+ this.input = new InputController({ element: canvas })
34
+ this.inputProfiles = new InputProfileCatalog()
35
+ this.cameraController = new CameraController(this.camera, this.input)
36
+ this.sceneSnapshots = new SceneSnapshotStore()
37
+ this.assetManifestLoader = new AssetManifestLoader()
38
+ this.updateLoop = new UpdateLoop()
39
+ this.updateLoop.add(this.cameraController)
40
+ this.clock = new FixedStepClock({ fixedDelta, maxSubSteps })
41
+ this.plugins = []
42
+ this.pluginApiVersion = Number.isFinite(pluginApiVersion) ? Math.max(1, Math.floor(pluginApiVersion)) : 1
43
+ this._pluginErrorHandlers = []
44
+ this._activeInputProfileName = ""
45
+ this.replay = new InputReplayTimeline()
46
+ this.bridge = new RuntimeBridge()
47
+ this.fallbackPolicy = fallbackPolicy === "strict" || fallbackPolicy === "silent" ? fallbackPolicy : "warn"
48
+ this._fallbackStatus = {
49
+ active: false,
50
+ reason: "",
51
+ policy: this.fallbackPolicy,
52
+ }
53
+
54
+ this._frameHandle = 0
55
+ this._running = false
56
+ this._frameInProgress = false
57
+ this._lastFrameTime = 0
58
+ this._onResize = this.#resize.bind(this)
59
+ this._onFrame = this.#frame.bind(this)
60
+ }
61
+
62
+ async init() {
63
+ try {
64
+ await this.renderer.init()
65
+ const capabilities = this.renderer.getCapabilities?.() ?? null
66
+ const hasWebGpu = capabilities?.supportsWebGPU !== false
67
+ if (!hasWebGpu) {
68
+ this.#activateFallback("webgpu-unavailable")
69
+ }
70
+ } catch (error) {
71
+ this.#activateFallback("renderer-init-failed")
72
+ if (this.fallbackPolicy === "strict") {
73
+ throw error
74
+ }
75
+ }
76
+
77
+ this.input.attach()
78
+ window.addEventListener("resize", this._onResize)
79
+ this.#resize()
80
+
81
+ await this.#invokePluginHookForAll("onInit", this)
82
+ this.bridge.emit("runtime:init", {
83
+ fallback: this.getFallbackStatus(),
84
+ })
85
+ }
86
+
87
+ setScene(scene) {
88
+ this.scene = scene
89
+ this.sceneManager.setScene(scene)
90
+ }
91
+
92
+ registerState(name, state) {
93
+ this.sceneManager.registerState(name, state)
94
+ }
95
+
96
+ setState(name, context = null) {
97
+ this.sceneManager.setState(name, context)
98
+ }
99
+
100
+ defineInputProfile(name, profile) {
101
+ this.inputProfiles.define(name, profile)
102
+ if (!this._activeInputProfileName) {
103
+ this.applyInputProfile(name)
104
+ }
105
+ }
106
+
107
+ getInputProfile(name) {
108
+ return this.inputProfiles.get(name)
109
+ }
110
+
111
+ listInputProfiles() {
112
+ return this.inputProfiles.list()
113
+ }
114
+
115
+ applyInputProfile(name) {
116
+ const profile = this.getInputProfile(name)
117
+ if (!profile) {
118
+ return false
119
+ }
120
+
121
+ this._activeInputProfileName = name
122
+ this.input.applyProfile(profile)
123
+ return true
124
+ }
125
+
126
+ getActiveInputProfileName() {
127
+ return this._activeInputProfileName ?? null
128
+ }
129
+
130
+ saveSceneSnapshot(name, scene = this.sceneManager.getActiveScene() ?? this.scene) {
131
+ this.sceneSnapshots.save(name, scene)
132
+ }
133
+
134
+ loadSceneSnapshot(name, { activate = true } = {}) {
135
+ const restored = this.sceneSnapshots.load(name)
136
+ if (!restored) {
137
+ return null
138
+ }
139
+
140
+ if (activate) {
141
+ this.setScene(restored)
142
+ }
143
+
144
+ return restored
145
+ }
146
+
147
+ listSceneSnapshots() {
148
+ return this.sceneSnapshots.list()
149
+ }
150
+
151
+ setCameraFollowTarget(target) {
152
+ this.cameraController.setFollowTarget(target)
153
+ }
154
+
155
+ setCameraBounds(bounds) {
156
+ this.cameraController.setBounds(bounds)
157
+ }
158
+
159
+ setCameraDeadZone(width, height) {
160
+ this.cameraController.setDeadZone(width, height)
161
+ }
162
+
163
+ use(plugin) {
164
+ if (!plugin || typeof plugin !== "object") {
165
+ throw new TypeError("GameRuntime.use requires a plugin object")
166
+ }
167
+
168
+ const pluginVersion = Number.isFinite(plugin.apiVersion) ? Math.floor(plugin.apiVersion) : this.pluginApiVersion
169
+ if (pluginVersion > this.pluginApiVersion) {
170
+ throw new Error(
171
+ `GameRuntime.use rejected plugin '${plugin.name ?? "anonymous"}' (apiVersion ${pluginVersion} > ${this.pluginApiVersion})`,
172
+ )
173
+ }
174
+
175
+ const capabilities = Array.isArray(plugin.capabilities) ? plugin.capabilities : []
176
+ const unsupported = capabilities.filter(
177
+ (capability) =>
178
+ capability !== "render-hooks" &&
179
+ capability !== "update-hooks" &&
180
+ capability !== "scene-state" &&
181
+ capability !== "input-profile",
182
+ )
183
+ if (unsupported.length > 0) {
184
+ throw new Error(`GameRuntime.use rejected plugin with unsupported capabilities: ${unsupported.join(", ")}`)
185
+ }
186
+
187
+ const pluginName =
188
+ typeof plugin.name === "string" && plugin.name.length > 0 ? plugin.name : `plugin-${this.plugins.length + 1}`
189
+ if (this.plugins.some((entry) => entry?.__pluginName === pluginName)) {
190
+ throw new Error(`GameRuntime.use rejected duplicate plugin name '${pluginName}'`)
191
+ }
192
+
193
+ const dependencies = Array.isArray(plugin.dependencies) ? plugin.dependencies : []
194
+ for (let i = 0; i < dependencies.length; i++) {
195
+ if (!this.plugins.some((entry) => entry?.__pluginName === dependencies[i])) {
196
+ throw new Error(`GameRuntime.use missing dependency '${dependencies[i]}' for plugin '${pluginName}'`)
197
+ }
198
+ }
199
+
200
+ const priority = Number.isFinite(plugin.priority) ? Math.floor(plugin.priority) : 0
201
+ const wrappedPlugin = {
202
+ ...plugin,
203
+ __pluginName: pluginName,
204
+ __enabled: plugin.enabled !== false,
205
+ __priority: priority,
206
+ }
207
+
208
+ this.plugins.push(wrappedPlugin)
209
+ this.plugins.sort((a, b) => (a.__priority ?? 0) - (b.__priority ?? 0))
210
+ return this
211
+ }
212
+
213
+ listPlugins() {
214
+ return this.plugins.map((plugin) => ({
215
+ name: plugin.__pluginName,
216
+ enabled: plugin.__enabled,
217
+ priority: plugin.__priority,
218
+ apiVersion: plugin.apiVersion ?? this.pluginApiVersion,
219
+ }))
220
+ }
221
+
222
+ setPluginEnabled(name, enabled = true) {
223
+ const plugin = this.plugins.find((entry) => entry.__pluginName === name)
224
+ if (!plugin) {
225
+ return false
226
+ }
227
+ plugin.__enabled = !!enabled
228
+ return true
229
+ }
230
+
231
+ onPluginError(handler) {
232
+ if (typeof handler === "function") {
233
+ this._pluginErrorHandlers.push(handler)
234
+ }
235
+ return this
236
+ }
237
+
238
+ startReplayRecording() {
239
+ this.replay.startRecording()
240
+ return this
241
+ }
242
+
243
+ stopReplayRecording() {
244
+ return this.replay.stopRecording()
245
+ }
246
+
247
+ startReplayPlayback(events) {
248
+ this.replay.startPlayback(events)
249
+ return this
250
+ }
251
+
252
+ stopReplayPlayback() {
253
+ this.replay.stopPlayback()
254
+ return this
255
+ }
256
+
257
+ getReplayDiagnostics(events = null) {
258
+ return this.replay.validate(events ?? this.replay.toJSON())
259
+ }
260
+
261
+ getFallbackStatus() {
262
+ return {
263
+ ...this._fallbackStatus,
264
+ }
265
+ }
266
+
267
+ onBridgeEvent(listener) {
268
+ return this.bridge.onEvent(listener)
269
+ }
270
+
271
+ registerBridgeCommand(name, handler) {
272
+ this.bridge.registerCommand(name, handler)
273
+ return this
274
+ }
275
+
276
+ executeBridgeCommand(name, payload = null) {
277
+ return this.bridge.execute(name, payload, this)
278
+ }
279
+
280
+ async preloadSnapshotAssets(name, { resolveTextureHandle = null, onProgress = null, signal = null } = {}) {
281
+ const plan = this.sceneSnapshots.createPreloadPlan(name, { resolveTextureHandle })
282
+ if (plan.urls.length === 0) {
283
+ return {
284
+ status: "ready",
285
+ loaded: 0,
286
+ failed: [],
287
+ unresolved: plan.unresolved,
288
+ total: plan.total,
289
+ }
290
+ }
291
+
292
+ const result = await this.assetManifestLoader.preloadUrls(plan.urls, {
293
+ onProgress,
294
+ signal,
295
+ })
296
+
297
+ return {
298
+ ...result,
299
+ unresolved: plan.unresolved,
300
+ }
301
+ }
302
+
303
+ start() {
304
+ if (this._running) {
305
+ return
306
+ }
307
+
308
+ this._running = true
309
+ this._frameInProgress = false
310
+ this._lastFrameTime = 0
311
+ this.clock.reset()
312
+ this._frameHandle = requestAnimationFrame(this._onFrame)
313
+ }
314
+
315
+ stop() {
316
+ if (!this._running) {
317
+ return
318
+ }
319
+
320
+ this._running = false
321
+ cancelAnimationFrame(this._frameHandle)
322
+ this._frameHandle = 0
323
+ }
324
+
325
+ destroy() {
326
+ this.stop()
327
+ this.input.detach()
328
+ this.updateLoop.clear()
329
+ void this.#invokePluginHookForAll("onDestroy", this)
330
+ window.removeEventListener("resize", this._onResize)
331
+ this.renderer.destroy()
332
+ }
333
+
334
+ #resize() {
335
+ this.renderer.setSize(window.innerWidth, window.innerHeight, window.devicePixelRatio || 1)
336
+ this.camera.setViewport(this.canvas.width, this.canvas.height)
337
+ }
338
+
339
+ async #frame(time) {
340
+ if (!this._running) {
341
+ return
342
+ }
343
+
344
+ if (this._frameInProgress) {
345
+ this._frameHandle = requestAnimationFrame(this._onFrame)
346
+ return
347
+ }
348
+
349
+ this._frameInProgress = true
350
+
351
+ try {
352
+ const deltaTime = this._lastFrameTime > 0 ? Math.max(0.0001, (time - this._lastFrameTime) * 0.001) : 1 / 60
353
+ this._lastFrameTime = time
354
+
355
+ const stepped = this.clock.consume(deltaTime)
356
+ for (let i = 0; i < stepped.steps.length; i++) {
357
+ const stepDelta = stepped.steps[i]
358
+
359
+ const replayEvents = this.replay.consume(stepDelta)
360
+ if (replayEvents.length > 0) {
361
+ const latest = replayEvents[replayEvents.length - 1]
362
+ if (latest?.snapshot) {
363
+ this.input.applySnapshot(latest.snapshot)
364
+ }
365
+ }
366
+
367
+ this.replay.record(stepDelta, this.input.captureSnapshot())
368
+
369
+ this.updateLoop.update(stepDelta)
370
+ this.sceneManager.update(stepDelta)
371
+ this.bridge.emit("runtime:update", {
372
+ deltaTime: stepDelta,
373
+ })
374
+
375
+ await this.#invokePluginHookForAll("onUpdate", this, stepDelta)
376
+ }
377
+
378
+ const activeScene = this.sceneManager.getActiveScene() ?? this.scene
379
+ if (!this._fallbackStatus.active) {
380
+ await this.renderer.render(activeScene, this.camera)
381
+ }
382
+ await this.#invokePluginHookForAll("onRender", this, stepped.alpha)
383
+ this.bridge.emit("runtime:render", {
384
+ alpha: stepped.alpha,
385
+ fallbackActive: this._fallbackStatus.active,
386
+ })
387
+ } finally {
388
+ this._frameInProgress = false
389
+ }
390
+
391
+ if (!this._running) {
392
+ return
393
+ }
394
+
395
+ this._frameHandle = requestAnimationFrame(this._onFrame)
396
+ }
397
+
398
+ async #invokePluginHook(plugin, hookName, ...args) {
399
+ if (!plugin || typeof plugin !== "object") {
400
+ return
401
+ }
402
+
403
+ if (plugin.__enabled === false) {
404
+ return
405
+ }
406
+
407
+ const hook = plugin[hookName]
408
+ if (typeof hook !== "function") {
409
+ return
410
+ }
411
+
412
+ try {
413
+ await hook(...args)
414
+ } catch (error) {
415
+ for (let i = 0; i < this._pluginErrorHandlers.length; i++) {
416
+ try {
417
+ this._pluginErrorHandlers[i]({
418
+ plugin: plugin.__pluginName ?? plugin.name ?? "anonymous",
419
+ hookName,
420
+ error,
421
+ })
422
+ } catch {
423
+ // Avoid cascading plugin error handler failures.
424
+ }
425
+ }
426
+ }
427
+ }
428
+
429
+ async #invokePluginHookForAll(hookName, ...args) {
430
+ for (let i = 0; i < this.plugins.length; i++) {
431
+ await this.#invokePluginHook(this.plugins[i], hookName, ...args)
432
+ }
433
+ }
434
+
435
+ #activateFallback(reason) {
436
+ this._fallbackStatus = {
437
+ active: true,
438
+ reason,
439
+ policy: this.fallbackPolicy,
440
+ }
441
+
442
+ if (this.fallbackPolicy === "warn") {
443
+ console.warn(`[GameRuntime] Falling back to no-render mode: ${reason}`)
444
+ }
445
+ }
446
+ }
@@ -0,0 +1,6 @@
1
+ import { GameRuntime } from "../adapters/runtime/GameRuntime.js"
2
+
3
+ /**
4
+ * Backward-compatible alias. Prefer `GameRuntime` from `src/index.js`.
5
+ */
6
+ export class GameApp extends GameRuntime {}
@@ -0,0 +1 @@
1
+ export { CameraController } from "../../core/camera/CameraController.js"
@@ -0,0 +1 @@
1
+ export { InputController } from "../../core/input/InputController.js"
@@ -0,0 +1 @@
1
+ export { InputProfileCatalog } from "../../core/input/InputProfileCatalog.js"
@@ -0,0 +1 @@
1
+ export { UpdateLoop } from "../../core/loop/UpdateLoop.js"
@@ -0,0 +1 @@
1
+ export { SceneManager } from "../../core/state/SceneManager.js"
@@ -0,0 +1 @@
1
+ export { SceneSnapshotStore } from "../../core/state/SceneSnapshotStore.js"