@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.
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Resolves scene serializer asset manifests and optionally preloads URL assets.
3
+ */
4
+ export class AssetManifestLoader {
5
+ resolve(manifest, { resolveTextureHandle = null } = {}) {
6
+ const textures = Array.isArray(manifest?.textures) ? manifest.textures : []
7
+ const resolved = []
8
+ const unresolved = []
9
+
10
+ for (let i = 0; i < textures.length; i++) {
11
+ const handle = textures[i]
12
+ if (!handle || typeof handle !== "object") {
13
+ continue
14
+ }
15
+
16
+ let url = null
17
+ if (handle.kind === "url" && typeof handle.value === "string") {
18
+ url = handle.value
19
+ } else if (typeof resolveTextureHandle === "function") {
20
+ url = resolveTextureHandle(handle) ?? null
21
+ }
22
+
23
+ if (typeof url === "string" && url.length > 0) {
24
+ resolved.push({ handle, url })
25
+ } else {
26
+ unresolved.push(handle)
27
+ }
28
+ }
29
+
30
+ return {
31
+ resolved,
32
+ unresolved,
33
+ total: textures.length,
34
+ }
35
+ }
36
+
37
+ async preloadUrls(urls, { onProgress = null, signal = null } = {}) {
38
+ const queue = Array.isArray(urls) ? urls.filter((url) => typeof url === "string" && url.length > 0) : []
39
+ let loaded = 0
40
+ const failed = []
41
+
42
+ for (let i = 0; i < queue.length; i++) {
43
+ if (signal?.aborted) {
44
+ return {
45
+ status: "aborted",
46
+ loaded,
47
+ failed,
48
+ total: queue.length,
49
+ }
50
+ }
51
+
52
+ const url = queue[i]
53
+ try {
54
+ const response = await fetch(url, {
55
+ method: "GET",
56
+ cache: "force-cache",
57
+ signal: signal ?? undefined,
58
+ })
59
+ if (!response.ok) {
60
+ failed.push({ url, status: response.status })
61
+ } else {
62
+ loaded += 1
63
+ }
64
+ } catch (_error) {
65
+ failed.push({ url, status: 0 })
66
+ }
67
+
68
+ onProgress?.({
69
+ loaded,
70
+ total: queue.length,
71
+ failed: failed.length,
72
+ current: url,
73
+ })
74
+ }
75
+
76
+ return {
77
+ status: "ready",
78
+ loaded,
79
+ failed,
80
+ total: queue.length,
81
+ }
82
+ }
83
+ }
@@ -0,0 +1,59 @@
1
+ import test from "node:test"
2
+ import assert from "node:assert/strict"
3
+
4
+ import { AssetManifestLoader } from "./AssetManifestLoader.js"
5
+
6
+ test("AssetManifestLoader resolves urls and unresolved handles", () => {
7
+ const loader = new AssetManifestLoader()
8
+ const result = loader.resolve(
9
+ {
10
+ textures: [
11
+ { kind: "url", value: "a.png" },
12
+ { kind: "asset", id: "hero" },
13
+ { kind: "asset", id: "missing" },
14
+ ],
15
+ },
16
+ {
17
+ resolveTextureHandle(handle) {
18
+ if (handle?.id === "hero") {
19
+ return "hero.png"
20
+ }
21
+ return null
22
+ },
23
+ },
24
+ )
25
+
26
+ assert.equal(result.total, 3)
27
+ assert.equal(result.resolved.length, 2)
28
+ assert.equal(result.unresolved.length, 1)
29
+ assert.equal(result.resolved[0].url, "a.png")
30
+ assert.equal(result.resolved[1].url, "hero.png")
31
+ })
32
+
33
+ test("AssetManifestLoader preloadUrls reports failures and progress", async () => {
34
+ const loader = new AssetManifestLoader()
35
+ const progress = []
36
+
37
+ const originalFetch = globalThis.fetch
38
+ globalThis.fetch = async (url) => {
39
+ if (url === "ok.png") {
40
+ return { ok: true, status: 200 }
41
+ }
42
+ return { ok: false, status: 404 }
43
+ }
44
+
45
+ try {
46
+ const result = await loader.preloadUrls(["ok.png", "bad.png"], {
47
+ onProgress(entry) {
48
+ progress.push(entry)
49
+ },
50
+ })
51
+
52
+ assert.equal(result.status, "ready")
53
+ assert.equal(result.loaded, 1)
54
+ assert.equal(result.failed.length, 1)
55
+ assert.equal(progress.length, 2)
56
+ } finally {
57
+ globalThis.fetch = originalFetch
58
+ }
59
+ })
@@ -0,0 +1,60 @@
1
+ export class SceneManager {
2
+ constructor() {
3
+ this._states = new Map()
4
+ this._activeStateName = ""
5
+ this._activeState = null
6
+ this._inlineScene = null
7
+ }
8
+
9
+ registerState(name, state) {
10
+ if (typeof name !== "string" || name.length === 0) {
11
+ throw new TypeError("SceneManager.registerState requires a non-empty state name")
12
+ }
13
+ if (!state || typeof state !== "object") {
14
+ throw new TypeError("SceneManager.registerState requires a state object")
15
+ }
16
+
17
+ this._states.set(name, {
18
+ scene: state.scene ?? null,
19
+ onEnter: typeof state.onEnter === "function" ? state.onEnter : null,
20
+ onExit: typeof state.onExit === "function" ? state.onExit : null,
21
+ onUpdate: typeof state.onUpdate === "function" ? state.onUpdate : null,
22
+ })
23
+ }
24
+
25
+ setState(name, context = null) {
26
+ if (this._activeState?.onExit) {
27
+ this._activeState.onExit(context)
28
+ }
29
+
30
+ const state = this._states.get(name)
31
+ if (!state) {
32
+ throw new Error(`Scene state not found: ${name}`)
33
+ }
34
+
35
+ this._activeStateName = name
36
+ this._activeState = state
37
+
38
+ if (state.onEnter) {
39
+ state.onEnter(context)
40
+ }
41
+ }
42
+
43
+ setScene(scene) {
44
+ this._inlineScene = scene
45
+ }
46
+
47
+ update(deltaTime) {
48
+ if (this._activeState?.onUpdate) {
49
+ this._activeState.onUpdate(deltaTime)
50
+ }
51
+ }
52
+
53
+ getActiveScene() {
54
+ return this._activeState?.scene ?? this._inlineScene
55
+ }
56
+
57
+ getActiveStateName() {
58
+ return this._activeStateName
59
+ }
60
+ }
@@ -0,0 +1,98 @@
1
+ import { SceneSerializer } from "@jts-studios/engine"
2
+
3
+ /**
4
+ * Gameplay-layer scene snapshot store backed by renderer-scene serialization.
5
+ */
6
+ export class SceneSnapshotStore {
7
+ constructor() {
8
+ this._snapshots = new Map()
9
+ }
10
+
11
+ save(name, scene) {
12
+ if (typeof name !== "string" || name.length === 0) {
13
+ throw new TypeError("SceneSnapshotStore.save requires a non-empty snapshot name")
14
+ }
15
+ const snapshot = SceneSerializer.serialize(scene)
16
+ const validation = SceneSerializer.validate(snapshot)
17
+ if (!validation.valid) {
18
+ throw new Error(`SceneSnapshotStore.save rejected invalid snapshot: ${validation.errors.join("; ")}`)
19
+ }
20
+ this._snapshots.set(name, snapshot)
21
+ }
22
+
23
+ load(name) {
24
+ const snapshot = this._snapshots.get(name)
25
+ if (!snapshot) {
26
+ return null
27
+ }
28
+ return SceneSerializer.deserialize(snapshot)
29
+ }
30
+
31
+ has(name) {
32
+ return this._snapshots.has(name)
33
+ }
34
+
35
+ validate(name, options = {}) {
36
+ const snapshot = this._snapshots.get(name)
37
+ if (!snapshot) {
38
+ return {
39
+ valid: false,
40
+ strict: !!options.strict,
41
+ errors: ["snapshot not found"],
42
+ warnings: [],
43
+ objectCount: 0,
44
+ }
45
+ }
46
+ return SceneSerializer.validate(snapshot, options)
47
+ }
48
+
49
+ getAssetManifest(name) {
50
+ const snapshot = this._snapshots.get(name)
51
+ if (!snapshot) {
52
+ return null
53
+ }
54
+ return SceneSerializer.extractAssetManifest(snapshot)
55
+ }
56
+
57
+ createPreloadPlan(name, { resolveTextureHandle = null } = {}) {
58
+ const manifest = this.getAssetManifest(name)
59
+ if (!manifest) {
60
+ return {
61
+ urls: [],
62
+ unresolved: [],
63
+ total: 0,
64
+ }
65
+ }
66
+
67
+ const urls = []
68
+ const unresolved = []
69
+ const textures = Array.isArray(manifest.textures) ? manifest.textures : []
70
+ for (let i = 0; i < textures.length; i++) {
71
+ const handle = textures[i]
72
+ if (handle?.kind === "url" && typeof handle.value === "string" && handle.value.length > 0) {
73
+ urls.push(handle.value)
74
+ continue
75
+ }
76
+
77
+ if (typeof resolveTextureHandle === "function") {
78
+ const resolved = resolveTextureHandle(handle)
79
+ if (typeof resolved === "string" && resolved.length > 0) {
80
+ urls.push(resolved)
81
+ continue
82
+ }
83
+ }
84
+
85
+ unresolved.push(handle)
86
+ }
87
+
88
+ return {
89
+ urls,
90
+ unresolved,
91
+ total: textures.length,
92
+ }
93
+ }
94
+
95
+ list() {
96
+ return Array.from(this._snapshots.keys())
97
+ }
98
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ export type PluginCapability = "render-hooks" | "update-hooks" | "scene-state" | "input-profile"
2
+
3
+ export interface GameRuntimePlugin {
4
+ name?: string
5
+ apiVersion?: number
6
+ capabilities?: PluginCapability[]
7
+ dependencies?: string[]
8
+ priority?: number
9
+ enabled?: boolean
10
+ onInit?(runtime: unknown): void | Promise<void>
11
+ onUpdate?(runtime: unknown, deltaTime: number): void | Promise<void>
12
+ onRender?(runtime: unknown, alpha: number): void | Promise<void>
13
+ onDestroy?(runtime: unknown): void | Promise<void>
14
+ }
15
+
16
+ export interface RuntimeBridgeEvent {
17
+ type: string
18
+ payload?: unknown
19
+ time: number
20
+ }
21
+
22
+ export declare class RuntimeBridge {
23
+ onEvent(listener: (event: RuntimeBridgeEvent) => void): () => void
24
+ emit(type: string, payload?: unknown): void
25
+ registerCommand(commandName: string, handler: (payload: unknown, context: unknown) => unknown): this
26
+ execute(commandName: string, payload?: unknown, context?: unknown): Promise<unknown>
27
+ }
package/src/index.js ADDED
@@ -0,0 +1,11 @@
1
+ export { CameraController } from "./core/camera/CameraController.js"
2
+ export { InputController } from "./core/input/InputController.js"
3
+ export { InputProfileCatalog } from "./core/input/InputProfileCatalog.js"
4
+ export { UpdateLoop } from "./core/loop/UpdateLoop.js"
5
+ export { FixedStepClock } from "./core/loop/FixedStepClock.js"
6
+ export { InputReplayTimeline } from "./core/loop/InputReplayTimeline.js"
7
+ export { RuntimeBridge } from "./core/runtime/RuntimeBridge.js"
8
+ export { SceneManager } from "./core/state/SceneManager.js"
9
+ export { SceneSnapshotStore } from "./core/state/SceneSnapshotStore.js"
10
+ export { AssetManifestLoader } from "./core/state/AssetManifestLoader.js"
11
+ export { GameRuntime } from "./adapters/runtime/GameRuntime.js"
package/src/main.js ADDED
@@ -0,0 +1,213 @@
1
+ import { GameRuntime } from "./index.js"
2
+ import { createBootstrapScene } from "./scenes/createBootstrapScene.js"
3
+
4
+ function createDebugPanel(app) {
5
+ const panel = document.createElement("aside")
6
+ panel.style.position = "fixed"
7
+ panel.style.left = "14px"
8
+ panel.style.top = "14px"
9
+ panel.style.width = "320px"
10
+ panel.style.padding = "12px"
11
+ panel.style.border = "1px solid rgba(255, 255, 255, 0.18)"
12
+ panel.style.borderRadius = "10px"
13
+ panel.style.background = "rgba(8, 12, 28, 0.84)"
14
+ panel.style.backdropFilter = "blur(6px)"
15
+ panel.style.color = "#dfe9ff"
16
+ panel.style.font = "12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"
17
+ panel.style.zIndex = "1000"
18
+ panel.style.display = "grid"
19
+ panel.style.gap = "8px"
20
+
21
+ const title = document.createElement("div")
22
+ title.textContent = "Web Game Engine Debug"
23
+ title.style.fontWeight = "700"
24
+ title.style.letterSpacing = "0.04em"
25
+ panel.appendChild(title)
26
+
27
+ const snapshotRow = document.createElement("div")
28
+ snapshotRow.style.display = "grid"
29
+ snapshotRow.style.gridTemplateColumns = "1fr auto"
30
+ snapshotRow.style.gap = "6px"
31
+
32
+ const snapshotInput = document.createElement("input")
33
+ snapshotInput.type = "text"
34
+ snapshotInput.value = "bootstrap-initial"
35
+ snapshotInput.placeholder = "snapshot name"
36
+ snapshotInput.style.padding = "6px"
37
+ snapshotInput.style.background = "rgba(255,255,255,0.08)"
38
+ snapshotInput.style.border = "1px solid rgba(255,255,255,0.2)"
39
+ snapshotInput.style.borderRadius = "6px"
40
+ snapshotInput.style.color = "#fff"
41
+
42
+ const snapshotSaveButton = document.createElement("button")
43
+ snapshotSaveButton.type = "button"
44
+ snapshotSaveButton.textContent = "Save"
45
+ snapshotSaveButton.style.padding = "6px 10px"
46
+ snapshotSaveButton.style.cursor = "pointer"
47
+
48
+ snapshotRow.append(snapshotInput, snapshotSaveButton)
49
+ panel.appendChild(snapshotRow)
50
+
51
+ const snapshotControls = document.createElement("div")
52
+ snapshotControls.style.display = "grid"
53
+ snapshotControls.style.gridTemplateColumns = "1fr auto"
54
+ snapshotControls.style.gap = "6px"
55
+
56
+ const snapshotSelect = document.createElement("select")
57
+ snapshotSelect.style.padding = "6px"
58
+ snapshotSelect.style.background = "rgba(255,255,255,0.08)"
59
+ snapshotSelect.style.border = "1px solid rgba(255,255,255,0.2)"
60
+ snapshotSelect.style.borderRadius = "6px"
61
+ snapshotSelect.style.color = "#fff"
62
+
63
+ const snapshotLoadButton = document.createElement("button")
64
+ snapshotLoadButton.type = "button"
65
+ snapshotLoadButton.textContent = "Load"
66
+ snapshotLoadButton.style.padding = "6px 10px"
67
+ snapshotLoadButton.style.cursor = "pointer"
68
+
69
+ snapshotControls.append(snapshotSelect, snapshotLoadButton)
70
+ panel.appendChild(snapshotControls)
71
+
72
+ const profileRow = document.createElement("div")
73
+ profileRow.style.display = "grid"
74
+ profileRow.style.gridTemplateColumns = "1fr auto"
75
+ profileRow.style.gap = "6px"
76
+
77
+ const profileSelect = document.createElement("select")
78
+ profileSelect.style.padding = "6px"
79
+ profileSelect.style.background = "rgba(255,255,255,0.08)"
80
+ profileSelect.style.border = "1px solid rgba(255,255,255,0.2)"
81
+ profileSelect.style.borderRadius = "6px"
82
+ profileSelect.style.color = "#fff"
83
+
84
+ const profileCopyButton = document.createElement("button")
85
+ profileCopyButton.type = "button"
86
+ profileCopyButton.textContent = "Copy JSON"
87
+ profileCopyButton.style.padding = "6px 10px"
88
+ profileCopyButton.style.cursor = "pointer"
89
+
90
+ profileRow.append(profileSelect, profileCopyButton)
91
+ panel.appendChild(profileRow)
92
+
93
+ const info = document.createElement("pre")
94
+ info.style.margin = "0"
95
+ info.style.padding = "8px"
96
+ info.style.maxHeight = "170px"
97
+ info.style.overflow = "auto"
98
+ info.style.whiteSpace = "pre-wrap"
99
+ info.style.background = "rgba(255,255,255,0.05)"
100
+ info.style.borderRadius = "6px"
101
+ panel.appendChild(info)
102
+
103
+ const refreshUI = () => {
104
+ const snapshots = app.listSceneSnapshots()
105
+ const currentSnapshot = snapshotSelect.value
106
+ snapshotSelect.innerHTML = ""
107
+ for (let i = 0; i < snapshots.length; i++) {
108
+ const option = document.createElement("option")
109
+ option.value = snapshots[i]
110
+ option.textContent = snapshots[i]
111
+ snapshotSelect.appendChild(option)
112
+ }
113
+ if (snapshots.includes(currentSnapshot)) {
114
+ snapshotSelect.value = currentSnapshot
115
+ }
116
+
117
+ const profiles = app.listInputProfiles()
118
+ const currentProfile = profileSelect.value
119
+ profileSelect.innerHTML = ""
120
+ for (let i = 0; i < profiles.length; i++) {
121
+ const option = document.createElement("option")
122
+ option.value = profiles[i]
123
+ option.textContent = profiles[i]
124
+ profileSelect.appendChild(option)
125
+ }
126
+ if (profiles.includes(currentProfile)) {
127
+ profileSelect.value = currentProfile
128
+ }
129
+
130
+ const profile = profileSelect.value ? app.getInputProfile(profileSelect.value) : null
131
+ info.textContent = JSON.stringify(
132
+ {
133
+ snapshots,
134
+ profiles,
135
+ selectedProfile: profileSelect.value || null,
136
+ selectedProfileData: profile,
137
+ hint: "Save/Load snapshots + inspect input profiles",
138
+ },
139
+ null,
140
+ 2,
141
+ )
142
+ }
143
+
144
+ snapshotSaveButton.addEventListener("click", () => {
145
+ const name = snapshotInput.value.trim()
146
+ if (!name) {
147
+ return
148
+ }
149
+ app.saveSceneSnapshot(name)
150
+ refreshUI()
151
+ })
152
+
153
+ snapshotLoadButton.addEventListener("click", () => {
154
+ const selected = snapshotSelect.value
155
+ if (!selected) {
156
+ return
157
+ }
158
+ app.loadSceneSnapshot(selected)
159
+ })
160
+
161
+ profileCopyButton.addEventListener("click", async () => {
162
+ const selected = profileSelect.value
163
+ if (!selected) {
164
+ return
165
+ }
166
+ const profile = app.getInputProfile(selected)
167
+ if (!profile) {
168
+ return
169
+ }
170
+
171
+ const text = JSON.stringify(profile, null, 2)
172
+ try {
173
+ await navigator.clipboard.writeText(text)
174
+ } catch {
175
+ // Clipboard API can be unavailable depending on browser permissions.
176
+ }
177
+ refreshUI()
178
+ })
179
+
180
+ profileSelect.addEventListener("change", refreshUI)
181
+ snapshotSelect.addEventListener("change", refreshUI)
182
+
183
+ refreshUI()
184
+ document.body.appendChild(panel)
185
+ }
186
+
187
+ async function boot() {
188
+ const canvas = document.getElementById("gpu-canvas")
189
+ const app = new GameRuntime({ canvas })
190
+ await app.init()
191
+ app.registerState("bootstrap", {
192
+ scene: createBootstrapScene(),
193
+ })
194
+ app.setState("bootstrap")
195
+
196
+ app.defineInputProfile("default", {
197
+ moveLeft: [{ type: "keyboard", code: "KeyA" }],
198
+ moveRight: [{ type: "keyboard", code: "KeyD" }],
199
+ })
200
+ app.defineInputProfile("camera-pan", {
201
+ pan: [{ type: "keyboard", chord: ["ShiftLeft", "Space"] }],
202
+ zoomIn: [{ type: "keyboard", code: "KeyQ" }],
203
+ zoomOut: [{ type: "keyboard", code: "KeyE" }],
204
+ })
205
+ app.saveSceneSnapshot("bootstrap-initial")
206
+ createDebugPanel(app)
207
+
208
+ app.start()
209
+ }
210
+
211
+ boot().catch((error) => {
212
+ console.error(error)
213
+ })
@@ -0,0 +1,17 @@
1
+ import { ColorRGBA, Scene, Sprite, Vec2 } from "@jts-studios/engine"
2
+
3
+ export function createBootstrapScene() {
4
+ const scene = new Scene({
5
+ backgroundColor: new ColorRGBA(0.04, 0.06, 0.1, 1),
6
+ })
7
+
8
+ scene.add(
9
+ new Sprite({
10
+ position: new Vec2(-40, -40),
11
+ size: new Vec2(80, 80),
12
+ color: new ColorRGBA(0.2, 0.8, 0.9, 1),
13
+ }),
14
+ )
15
+
16
+ return scene
17
+ }