@waica/engine 0.29.0 → 0.30.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/README.md CHANGED
@@ -25,6 +25,13 @@ A `Game` draws through three's `WebGPURenderer` (ADR 0025): WebGPU when the brow
25
25
  - **Tests under happy-dom.** happy-dom has no GPU: a project test that builds a `Game` replaces `WebGPURenderer` from `three/webgpu` with a fake (it previously replaced `WebGLRenderer` from `three`), whose `init()` resolves.
26
26
  - **Run Sessions.** A Run Session waits for the Render Backend before it reports ready, steps or screenshots, and its snapshots carry `backend`. A renderer that cannot initialize ends `start_project` with a runtime error that names the failure.
27
27
 
28
+ ## Migrating to 0.29.1: checkpoint banners on the walls
29
+
30
+ A fix release for the third-person demo, whose checkpoint banners stood free in the yard: KayKit's banner is single-sided and modelled to hang on a wall, so it floated and vanished from behind. Every existing scene file loads as it did; what changes:
31
+
32
+ - **`Checkpoint.spawnOffset`** (new, a `vector3`, default `[0, 0, 0]`: unchanged) moves where the player respawns from the checkpoint's position. In a 3D scene it is in the entity's local frame, turned and scaled with it like a `Collider`'s `offset`; in 2D it is in world units, like a `Hitbox`'s or a `Solid`'s offset, whatever the node's scale or rotation. `validate_project` reports a `spawnOffset` that is not three finite numbers as `invalid-checkpoint-param`.
33
+ - **`@waica/archetype-third-person`'s `objects/checkpoint` prefab is a wall banner.** Its origin goes 0.35 inside a wall's inner face (the cloth's back then lies flush on it), turned so its local +z faces away from the wall. Its sensor `Collider` moves to `offset: [0, 1.5, 1.35]` (same 2 × 3 × 2 size) and its `Checkpoint` gets `spawnOffset: [0, 0, 1.35]`, so the sensor starts at the wall's face and the player respawns standing on the floor 1 in front of it. A project created earlier keeps its own `src/objects/checkpoint.object.json` and behaves as before; one that takes the new prefab should hang its checkpoints on walls. The demo's checkpoints are now `Checkpoint West` at `(-9.85, 0, 4)` turned `[0, 90, 0]` and `Checkpoint East` at `(9.85, 0, 4)` turned `[0, -90, 0]`.
34
+
28
35
  ## Migrating to 0.29.0: third-person archetype
29
36
 
30
37
  The release adds `@waica/archetype-third-person` (issue #181, ADR 0031) and the engine and behavior pieces it needs. Every existing scene file loads as it did; what changes:
@@ -480,3 +487,19 @@ game.assets.status // { pending, loaded, failed } — a fresh object on every re
480
487
  - **Models.** `game.assets.model(uri)` is the glTF half of the same cache (see [3D scenes](#3d-scenes)): one load per URL, `status` and `ready()` count it, `preload` routes `.glb` and `.gltf` to it.
481
488
  - **`GameOptions.textures`.** Replaces the real `THREE.TextureLoader` backend for that Game (ADR 0013's seam, applied to textures): `happy-dom` decodes no images, so a project's own tests inject a backend that resolves at once. The Runtime Bridge reports `assets` in its metadata under the `'assets'` capability, and a Run Session waits for `pending === 0` at readiness, after a `scene` operation and before every screenshot.
482
489
 
490
+ ## The host platform: quit, fullscreen and achievements
491
+
492
+ Every `Game` talks to the host it runs in through a replaceable platform seam (ADR 0032): the browser, or the desktop shell that `waica export desktop` packages the game into. A game asks the same questions on both and gets an honest answer:
493
+
494
+ ```ts
495
+ if (game.canQuit) menu.add('Quit', () => game.quit())
496
+ await game.setFullscreen(!game.isFullscreen())
497
+ game.achievements.unlock('first-kill')
498
+ game.achievements.setStat('kills', 3)
499
+ const kills = await game.achievements.getStat('kills') // number | undefined
500
+ ```
501
+
502
+ - **`game.canQuit` and `game.quit()`.** `canQuit` is `true` in the desktop shell, where `quit()` closes the app, and `false` on the web, where a page cannot close itself: there `quit()` does nothing (the loop keeps running and nothing throws), so a game hides its Quit button instead.
503
+ - **`game.setFullscreen(on)` and `game.isFullscreen()`.** On the web, the browser's Fullscreen API: `setFullscreen(true)` requests it for the page and `setFullscreen(false)` leaves it; `isFullscreen()` is `document.fullscreenElement !== null`. A browser refuses a request made without a user gesture: the promise still resolves, never rejects, and `isFullscreen()` stays `false`, so call it from a key or click handler. In the desktop shell both go to the window, and F11 and Alt+Enter toggle it too. There `isFullscreen()` is a synchronous round trip to the main process that blocks the page until it answers, so read it when a key is pressed or a menu opens, not in every `update`.
504
+ - **`game.achievements`.** Store-agnostic: `unlock(id)`, `setStat(id, value)`, `getStat(id): Promise<number | undefined>` and `available`. On the web, and in a desktop build exported without Steam, `available` is `false`, `unlock` and `setStat` do nothing and `getStat` resolves `undefined`. In a desktop build with Steam (`game.json`'s `desktop.steam`) and a running Steam client, `available` is `true` and each call reaches Steamworks; declare every id in `desktop.steam.achievements` or `desktop.steam.stats`.
505
+ - **`GameOptions.platform`.** Absent, the Game picks the desktop backend when `window.waicaDesktop` (the shell's preload bridge, `DesktopBridge`) exists, and `WebPlatformBackend` otherwise. A `window.waicaDesktop` of another shape (an engine and a shell whose versions disagree) falls back to the web backend with a console warning that names what it lacks. A project's own tests pass a fake `PlatformBackend` to observe every call. `new WebPlatformBackend(() => element)` fullscreens that element instead of the whole page: the editor's Play passes its Viewport's surface.
package/dist/game.d.ts CHANGED
@@ -16,6 +16,7 @@ import { GameTime } from './game-time.js';
16
16
  import { Input, type InputBindings } from './input.js';
17
17
  import { PhysicsHost } from './physics-3d/physics-host.js';
18
18
  import type { PhysicsBackend } from './physics-3d/rapier-module.js';
19
+ import type { PlatformAchievements, PlatformBackend } from './platform/platform-backend.js';
19
20
  import { Pointer } from './pointer.js';
20
21
  import { GamePost } from './post-effects.js';
21
22
  import { type RenderBackend } from './render-readiness.js';
@@ -70,6 +71,13 @@ export interface GameOptions {
70
71
  * pass the module they initialized once, or a rejecting stub.
71
72
  */
72
73
  physics?: PhysicsBackend;
74
+ /**
75
+ * The host platform behind `quit`, `setFullscreen` and `achievements`
76
+ * (ADR 0032, the seam ADR 0013 describes): the desktop shell's bridge when
77
+ * `window.waicaDesktop` exists, otherwise the browser. A project's own
78
+ * tests pass a fake.
79
+ */
80
+ platform?: PlatformBackend;
73
81
  }
74
82
  export type UpdateFn = (dt: number) => void;
75
83
  export interface SpawnPrefabOptions {
@@ -126,6 +134,8 @@ export declare class Game {
126
134
  * or collisions — the editor's edit mode.
127
135
  */
128
136
  simulate: boolean;
137
+ /** The browser or the desktop shell (ADR 0032). */
138
+ private readonly platform;
129
139
  /** WebGPU when the browser offers it, else three's WebGL2 fallback (ADR 0025). */
130
140
  private readonly renderer;
131
141
  private readonly readiness;
@@ -182,6 +192,20 @@ export declare class Game {
182
192
  ready(): Promise<void>;
183
193
  /** The Render Backend the renderer settled on; null until ready() resolved. */
184
194
  get backend(): RenderBackend | null;
195
+ /** Whether `quit()` closes the game: true in the desktop shell, false on the web (ADR 0032). */
196
+ get canQuit(): boolean;
197
+ /** Store achievements and stats (ADR 0032): unavailable on the web, where every call is a no-op. */
198
+ get achievements(): PlatformAchievements;
199
+ /** Closes the desktop app; on the web, where a page cannot close itself, it does nothing. */
200
+ quit(): void;
201
+ /** Enters or leaves fullscreen. Never rejects: a browser refuses without a user gesture. */
202
+ setFullscreen(on: boolean): Promise<void>;
203
+ /**
204
+ * Whether the game is fullscreen now. In the desktop shell it asks the main
205
+ * process synchronously: read it on a key press or when a menu opens, not
206
+ * every frame.
207
+ */
208
+ isFullscreen(): boolean;
185
209
  /** Creates a live entity in the scene. */
186
210
  spawn(name: string): Entity;
187
211
  /** Instantiates a registered prefab after a scene has supplied the registry. */
package/dist/game.js CHANGED
@@ -14,6 +14,7 @@ import { consumeSimulationSteps, MAX_CHAINED_HOPS, SIMULATION_STEP, snapElapsedT
14
14
  import { advanceGameTime, GameTime } from './game-time.js';
15
15
  import { Input } from './input.js';
16
16
  import { PhysicsHost } from './physics-3d/physics-host.js';
17
+ import { defaultPlatformBackend } from './platform/desktop-platform-backend.js';
17
18
  import { Pointer } from './pointer.js';
18
19
  import { GamePost } from './post-effects.js';
19
20
  import { activeRuntimeBridgeHook, EngineRuntimeBridge, } from './runtime-bridge.js';
@@ -78,6 +79,8 @@ export class Game {
78
79
  * or collisions — the editor's edit mode.
79
80
  */
80
81
  simulate = true;
82
+ /** The browser or the desktop shell (ADR 0032). */
83
+ platform;
81
84
  /** WebGPU when the browser offers it, else three's WebGL2 fallback (ADR 0025). */
82
85
  renderer;
83
86
  readiness;
@@ -168,6 +171,7 @@ export class Game {
168
171
  });
169
172
  this.spriteBatches = createSpriteBatches(this, this.assets);
170
173
  this.physics = new PhysicsHost(this, options.physics);
174
+ this.platform = options.platform ?? defaultPlatformBackend();
171
175
  this.cameraEffects = new CameraEffects({
172
176
  host: () => canvas.parentElement ?? document.body,
173
177
  // One screen pixel in world units: the shake snaps to it (never the base).
@@ -213,6 +217,30 @@ export class Game {
213
217
  get backend() {
214
218
  return this.readiness.backend;
215
219
  }
220
+ /** Whether `quit()` closes the game: true in the desktop shell, false on the web (ADR 0032). */
221
+ get canQuit() {
222
+ return this.platform.canQuit;
223
+ }
224
+ /** Store achievements and stats (ADR 0032): unavailable on the web, where every call is a no-op. */
225
+ get achievements() {
226
+ return this.platform.achievements;
227
+ }
228
+ /** Closes the desktop app; on the web, where a page cannot close itself, it does nothing. */
229
+ quit() {
230
+ this.platform.quit();
231
+ }
232
+ /** Enters or leaves fullscreen. Never rejects: a browser refuses without a user gesture. */
233
+ setFullscreen(on) {
234
+ return this.platform.setFullscreen(on);
235
+ }
236
+ /**
237
+ * Whether the game is fullscreen now. In the desktop shell it asks the main
238
+ * process synchronously: read it on a key press or when a menu opens, not
239
+ * every frame.
240
+ */
241
+ isFullscreen() {
242
+ return this.platform.isFullscreen();
243
+ }
216
244
  /** Creates a live entity in the scene. */
217
245
  spawn(name) {
218
246
  const entity = new Entity(this, name);
package/dist/index.d.ts CHANGED
@@ -11,6 +11,7 @@ export { AudioSubsystem } from './audio/audio-subsystem.js';
11
11
  export type { AudioSubsystemOptions } from './audio/audio-subsystem.js';
12
12
  export type { AudioBackend, AudioResource, BackendPlayHandle, BackendPlayOptions } from './audio/backend.js';
13
13
  export type { AudioChannelState, AudioPlayOptions, LiveSoundInfo, SoundHandle } from './audio/types.js';
14
+ export { WebPlatformBackend, type DesktopBridge, type PlatformAchievements, type PlatformBackend } from './platform/platform-backend.js';
14
15
  export { installDirectionalAnimation, installedDirectionalAnimation, isAnimationFacingProvider, resolveDirectionalClip, } from './animation/directional.js';
15
16
  export type { AnimationFacingProvider, DirectionalAnimation, DirectionalFallback, ResolvedDirectionalClip, } from './animation/directional.js';
16
17
  export { isClipTarget } from './animation/clip-target.js';
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ export { AssetLoader } from './assets/asset-loader.js';
3
3
  export { GltfModelBackend } from './assets/model-backend.js';
4
4
  export { glbClipNames } from './assets/glb-clip-names.js';
5
5
  export { AudioSubsystem } from './audio/audio-subsystem.js';
6
+ export { WebPlatformBackend } from './platform/platform-backend.js';
6
7
  export { installDirectionalAnimation, installedDirectionalAnimation, isAnimationFacingProvider, resolveDirectionalClip, } from './animation/directional.js';
7
8
  export { isClipTarget } from './animation/clip-target.js';
8
9
  export { isYSortBatchParticipant, isYSortParticipant, ySortZ } from './render-sort.js';
@@ -0,0 +1,20 @@
1
+ import { type DesktopBridge, type PlatformAchievements, type PlatformBackend } from './platform-backend.js';
2
+ /** `window.waicaDesktop` is page-reachable data: only the shape the shell exposes counts as the bridge. */
3
+ export declare function isDesktopBridge(value: unknown): value is DesktopBridge;
4
+ /** The packaged desktop app as a platform: every call goes to the shell's main process. */
5
+ export declare class DesktopPlatformBackend implements PlatformBackend {
6
+ private readonly bridge;
7
+ readonly canQuit = true;
8
+ readonly achievements: PlatformAchievements;
9
+ constructor(bridge: DesktopBridge);
10
+ quit(): void;
11
+ setFullscreen(on: boolean): Promise<void>;
12
+ isFullscreen(): boolean;
13
+ }
14
+ /**
15
+ * The desktop backend inside the shell (`window.waicaDesktop` exists), the
16
+ * web one everywhere else. A `window.waicaDesktop` of another shape (an
17
+ * engine and a shell from versions that disagree) falls back to the web
18
+ * backend and says what it lacks, so a missing Quit has a cause in the console.
19
+ */
20
+ export declare function defaultPlatformBackend(): PlatformBackend;
@@ -0,0 +1,101 @@
1
+ import { NO_ACHIEVEMENTS, WebPlatformBackend, } from './platform-backend.js';
2
+ function isObject(value) {
3
+ return typeof value === 'object' && value !== null;
4
+ }
5
+ function isAchievements(value) {
6
+ return (isObject(value) &&
7
+ typeof value['available'] === 'boolean' &&
8
+ typeof value['unlock'] === 'function' &&
9
+ typeof value['setStat'] === 'function' &&
10
+ typeof value['getStat'] === 'function');
11
+ }
12
+ const BRIDGE_METHODS = ['quit', 'setFullscreen', 'isFullscreen'];
13
+ /** The members a `window.waicaDesktop` lacks (or has with the wrong type) to be the shell's bridge. */
14
+ function bridgeGaps(value) {
15
+ if (!isObject(value))
16
+ return ['every member'];
17
+ const gaps = BRIDGE_METHODS.filter((name) => typeof value[name] !== 'function');
18
+ if (value['achievements'] !== undefined && !isAchievements(value['achievements']))
19
+ gaps.push('achievements');
20
+ return gaps;
21
+ }
22
+ /** `window.waicaDesktop` is page-reachable data: only the shape the shell exposes counts as the bridge. */
23
+ export function isDesktopBridge(value) {
24
+ return bridgeGaps(value).length === 0;
25
+ }
26
+ /** A throw from the bridge (say, the window closing after `quit()`) stays here instead of reaching the game's script. */
27
+ function callShell(call) {
28
+ try {
29
+ call();
30
+ }
31
+ catch {
32
+ // The shell is gone or refused: an achievement call is fire-and-forget.
33
+ }
34
+ }
35
+ /**
36
+ * The shell's achievements, held to the contract the engine documents: no
37
+ * call throws, and `getStat` resolves a number or `undefined`, whatever the
38
+ * bridge does.
39
+ */
40
+ function forwardedAchievements(shell) {
41
+ return {
42
+ available: true,
43
+ unlock: (id) => callShell(() => shell.unlock(id)),
44
+ setStat: (id, value) => callShell(() => shell.setStat(id, value)),
45
+ getStat: async (id) => {
46
+ try {
47
+ const value = await shell.getStat(id);
48
+ return typeof value === 'number' ? value : undefined;
49
+ }
50
+ catch {
51
+ return undefined;
52
+ }
53
+ },
54
+ };
55
+ }
56
+ /** The packaged desktop app as a platform: every call goes to the shell's main process. */
57
+ export class DesktopPlatformBackend {
58
+ bridge;
59
+ canQuit = true;
60
+ achievements;
61
+ constructor(bridge) {
62
+ this.bridge = bridge;
63
+ const shell = bridge.achievements;
64
+ this.achievements = shell?.available === true ? forwardedAchievements(shell) : NO_ACHIEVEMENTS;
65
+ }
66
+ quit() {
67
+ this.bridge.quit();
68
+ }
69
+ async setFullscreen(on) {
70
+ try {
71
+ await this.bridge.setFullscreen(on);
72
+ }
73
+ catch {
74
+ // The window is gone or the shell refused: the state stays what isFullscreen() reports.
75
+ }
76
+ }
77
+ isFullscreen() {
78
+ try {
79
+ return this.bridge.isFullscreen() === true;
80
+ }
81
+ catch {
82
+ // The window is gone: it is not fullscreen.
83
+ return false;
84
+ }
85
+ }
86
+ }
87
+ /**
88
+ * The desktop backend inside the shell (`window.waicaDesktop` exists), the
89
+ * web one everywhere else. A `window.waicaDesktop` of another shape (an
90
+ * engine and a shell from versions that disagree) falls back to the web
91
+ * backend and says what it lacks, so a missing Quit has a cause in the console.
92
+ */
93
+ export function defaultPlatformBackend() {
94
+ const candidate = Reflect.get(globalThis, 'waicaDesktop');
95
+ if (isDesktopBridge(candidate))
96
+ return new DesktopPlatformBackend(candidate);
97
+ if (candidate !== undefined) {
98
+ console.warn(`[waica] window.waicaDesktop is not the desktop shell's bridge (${bridgeGaps(candidate).join(', ')} missing or wrong): the game runs without quit, native fullscreen or achievements`);
99
+ }
100
+ return new WebPlatformBackend();
101
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Store achievements and stats, whichever store the game ships on (issue #200).
3
+ * Unavailable on the web and in a desktop build without a store client, where
4
+ * every call is a no-op and `getStat` resolves `undefined`.
5
+ */
6
+ export interface PlatformAchievements {
7
+ /** True only while a store client is connected (the desktop shell with Steam). */
8
+ readonly available: boolean;
9
+ /** Activates an achievement by its store id. */
10
+ unlock(id: string): void;
11
+ /** Sets an integer stat by its store id and stores it. */
12
+ setStat(id: string, value: number): void;
13
+ /** A stat's stored value; `undefined` where none can be read. */
14
+ getStat(id: string): Promise<number | undefined>;
15
+ }
16
+ /**
17
+ * Everything Game needs from the host it runs in (ADR 0032): the browser, or
18
+ * the desktop shell's preload bridge. The web implementation is the default;
19
+ * the desktop one is picked when `window.waicaDesktop` exists; a project's
20
+ * own tests replace either through `GameOptions.platform`.
21
+ */
22
+ export interface PlatformBackend {
23
+ /** Whether `quit()` can close the game: false where a page cannot close itself. */
24
+ readonly canQuit: boolean;
25
+ /** Closes the game where `canQuit` is true; a no-op elsewhere. */
26
+ quit(): void;
27
+ /** Enters or leaves fullscreen. Never rejects: a refused request leaves `isFullscreen()` as it was. */
28
+ setFullscreen(on: boolean): Promise<void>;
29
+ /**
30
+ * Whether the game is fullscreen now. In the desktop shell this is a
31
+ * synchronous round trip to the main process, which blocks the page until
32
+ * it answers: read it when a key is pressed or a menu opens, not every frame.
33
+ */
34
+ isFullscreen(): boolean;
35
+ readonly achievements: PlatformAchievements;
36
+ }
37
+ /** Achievements where no store client is connected: unavailable, and every call a no-op. */
38
+ export declare const NO_ACHIEVEMENTS: PlatformAchievements;
39
+ /**
40
+ * What the desktop shell's preload exposes as `window.waicaDesktop` through
41
+ * `contextBridge` (ADR 0032). `achievements` is absent or unavailable without
42
+ * Steam.
43
+ */
44
+ export interface DesktopBridge {
45
+ quit(): void;
46
+ setFullscreen(on: boolean): Promise<void>;
47
+ isFullscreen(): boolean;
48
+ achievements?: PlatformAchievements;
49
+ }
50
+ /**
51
+ * The browser as a platform (ADR 0032): a page cannot close itself, fullscreen
52
+ * is the Fullscreen API, and there is no store, so no achievements.
53
+ */
54
+ export declare class WebPlatformBackend implements PlatformBackend {
55
+ private readonly fullscreenTarget;
56
+ readonly canQuit = false;
57
+ readonly achievements: PlatformAchievements;
58
+ /**
59
+ * @param fullscreenTarget The element `setFullscreen(true)` asks to fill the
60
+ * screen: the whole page by default. A host that shows the game inside a
61
+ * larger page, like the editor's Viewport, passes the game's own surface.
62
+ */
63
+ constructor(fullscreenTarget?: () => Element);
64
+ quit(): void;
65
+ setFullscreen(on: boolean): Promise<void>;
66
+ isFullscreen(): boolean;
67
+ }
@@ -0,0 +1,42 @@
1
+ /** Achievements where no store client is connected: unavailable, and every call a no-op. */
2
+ export const NO_ACHIEVEMENTS = Object.freeze({
3
+ available: false,
4
+ unlock: () => { },
5
+ setStat: () => { },
6
+ getStat: () => Promise.resolve(undefined),
7
+ });
8
+ /**
9
+ * The browser as a platform (ADR 0032): a page cannot close itself, fullscreen
10
+ * is the Fullscreen API, and there is no store, so no achievements.
11
+ */
12
+ export class WebPlatformBackend {
13
+ fullscreenTarget;
14
+ canQuit = false;
15
+ achievements = NO_ACHIEVEMENTS;
16
+ /**
17
+ * @param fullscreenTarget The element `setFullscreen(true)` asks to fill the
18
+ * screen: the whole page by default. A host that shows the game inside a
19
+ * larger page, like the editor's Viewport, passes the game's own surface.
20
+ */
21
+ constructor(fullscreenTarget = () => document.documentElement) {
22
+ this.fullscreenTarget = fullscreenTarget;
23
+ }
24
+ quit() {
25
+ // A page cannot close its own tab; canQuit tells the game not to offer it.
26
+ }
27
+ async setFullscreen(on) {
28
+ try {
29
+ if (on)
30
+ await this.fullscreenTarget().requestFullscreen();
31
+ else
32
+ await document.exitFullscreen();
33
+ }
34
+ catch {
35
+ // Refused (no user gesture, an iframe without permission) or no Fullscreen API at all.
36
+ }
37
+ }
38
+ isFullscreen() {
39
+ // `undefined` where the page has no Fullscreen API: never fullscreen there.
40
+ return Boolean(document.fullscreenElement);
41
+ }
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waica/engine",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "Waica game engine core — archetype-driven, web-first, 2D & 3D",
5
5
  "license": "MIT",
6
6
  "type": "module",