@waica/engine 0.17.0 → 0.19.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 +44 -0
- package/dist/assets/asset-loader.d.ts +76 -0
- package/dist/assets/asset-loader.js +138 -0
- package/dist/assets/texture-backend.d.ts +17 -0
- package/dist/assets/texture-backend.js +15 -0
- package/dist/camera-effects.d.ts +142 -0
- package/dist/camera-effects.js +334 -0
- package/dist/components/animated-sprite.d.ts +17 -4
- package/dist/components/animated-sprite.js +41 -12
- package/dist/components/sprite.d.ts +6 -0
- package/dist/components/sprite.js +28 -6
- package/dist/components/tilemap.d.ts +6 -0
- package/dist/components/tilemap.js +28 -4
- package/dist/game-time.d.ts +4 -0
- package/dist/game-time.js +2 -1
- package/dist/game.d.ts +21 -0
- package/dist/game.js +53 -8
- package/dist/index.d.ts +6 -1
- package/dist/index.js +2 -0
- package/dist/runtime-bridge.d.ts +13 -2
- package/dist/runtime-bridge.js +15 -2
- package/dist/runtime-inspection.d.ts +9 -0
- package/dist/runtime-inspection.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -194,6 +194,28 @@ game.time.now // seconds of Game Time since the Game started
|
|
|
194
194
|
- **Scope.** A timer or tween is scene-scoped by default: `unloadScene()` and every scene load — including a Game's first — cancel it, running no callback. `{ scope: 'session' }` survives a scene change. `{ owner: entity }` cancels it immediately when that entity is destroyed, whatever its scope; an owner already dead at scheduling time yields an inactive handle. `game.dispose()` cancels everything in both scopes. This is the opposite default from `game.onUpdate`/`game.events` (ADR 0011), which survive a scene change by construction, and the same one `game.audio.play()` uses (ADR 0012) — see ADR 0017 for why timers follow audio's rule rather than the host-subscription one: a timer's callback almost always closes over the scene that scheduled it.
|
|
195
195
|
- **No Promises.** Nothing here returns one, and nothing is async — a `.then` continuation is not step-exact (it runs after the whole synchronous frame), which is exactly what `game.time` exists to avoid. Compose delays with `after`, not `await`.
|
|
196
196
|
|
|
197
|
+
## game.cameraEffects: shake, fade and flash
|
|
198
|
+
|
|
199
|
+
Every `Game` owns a `cameraEffects: CameraEffects` service beside `game.camera`, which stays the plain `THREE.OrthographicCamera` (ADR 0020). Effects advance only inside a Simulation Step, right after the scene camera, so nothing moves while the Game is not simulating, is stopped, or a Run Session is paused; `step { frames: N }` advances them by exactly N steps.
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
game.cameraEffects.shake({ intensity: 0.3, seconds: 0.25 }) // world units, decaying to 0
|
|
203
|
+
const out = game.cameraEffects.fade({ to: 'black', seconds: 0.4 }) // 'black' | 'white' | '#rrggbb' | 'clear'
|
|
204
|
+
game.cameraEffects.flash({ color: 'white', seconds: 0.15 }) // up to 1, back to exactly 0
|
|
205
|
+
out.cancel() // opacity stays where it was
|
|
206
|
+
await out.done // true: completed; false: cancelled or replaced
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
- **Shake.** An offset added to the camera only while drawing (the world render and Anchored Pieces), never to the base center that follow, deadzone and limits compute, so smoothing never feeds on it. Each axis stays within `intensity · (1 − ease(t / seconds))` (`easing` takes `game.time`'s names, `'linear'` by default) and is exactly `{0, 0}` from `seconds` on. Overlapping shakes apply the largest current amplitude. The jitter is deterministic per Simulation Step (no `Math.random`), so a Run Session reproduces it. It is not re-clamped to `limits`: at a map edge up to `intensity` beyond them can show. Under a fixed `resolution` the offset snaps to whole screen pixels; the base center is never snapped.
|
|
210
|
+
- **Fade.** Carries an HTML layer over the game viewport — inside the letterbox, above the world, UI Pieces and Anchored Pieces — from its current opacity to 1 in the given color, or to 0 with `'clear'`, and then holds. A new `fade` cancels a running one (its `done` resolves `false`) and starts from the current opacity.
|
|
211
|
+
- **Flash.** Its own layer above the Fade, shown at opacity 1 and returned to exactly 0 over `seconds`; it never changes the Fade.
|
|
212
|
+
- **Scope.** A Fade is session-scoped: it survives `unloadScene()` and every scene load with its color, opacity and any running progress, so it can cover a scene change. A Shake or Flash is scene-scoped: `unloadScene()` ends it, running no callback (its `done` resolves `false`). `game.dispose()` ends everything and removes the layers. This follows the audio and timer scope notes above (ADR 0011, ADR 0017): a transition fade is exactly the case that must cross a scene change.
|
|
213
|
+
- **Invalid arguments never throw.** A non-finite or negative `seconds`/`intensity`, an unknown color or an unknown easing logs one `[waica]` warning and returns a handle whose `done` resolves `false`; no effect changes.
|
|
214
|
+
- **Scene Transition.** `SceneTransition` takes `fadeSeconds` (default 0: a hard cut) and `fadeColor` (default `'black'`): it fades out over `fadeSeconds`, loads its destination, then clears over `fadeSeconds` in the incoming scene. Simulation and input keep running; the component ignores further triggers during its outgoing fade. The Runtime Bridge `scene` operation stays a hard cut.
|
|
215
|
+
- **Runtime Snapshot.** Every snapshot carries `camera: { shake: { x, y }, fade: { color, opacity }, flash: { color, opacity } }` from the last completed step, under the `'camera-effects'` capability.
|
|
216
|
+
|
|
217
|
+
A Fade to a color that nothing clears leaves the view covered; the engine does not warn.
|
|
218
|
+
|
|
197
219
|
## game.ui.attach: Anchored Pieces
|
|
198
220
|
|
|
199
221
|
A UI Piece shown with `game.ui.show(name)` is a screen-space singleton. `game.ui.attach(piece, entity, options?)` instead creates an **Anchored Piece**: a new instance of the piece that follows `entity` across the screen, with its own shadow root and its own values. Every call is a new instance — five orcs can each carry a `health-bar` — and the screen piece of the same name is never mounted, shown or changed by it.
|
|
@@ -224,3 +246,25 @@ bar.remove()
|
|
|
224
246
|
- **Runtime Snapshot.** Every snapshot carries `ui: { shown, anchored }`: the visible screen pieces by name, and each live instance in creation order as `{ piece, entity, x, y, clipped, values }`, with the pixel coordinates of its last placement; `values` are bounded like component state (a string over 4 KiB becomes a `$waica: 'truncated'` marker), and once every entity is cut the 1 MiB snapshot cap drops instances from the end.
|
|
225
247
|
|
|
226
248
|
The trade-off is ADR 0018's: Anchored Pieces are HTML drawn over the game view, not text rendered in the three scene. They always draw above the world — a label behind a tree draws over it — hide with the rest of the UI overlay while the Game is not simulating (including the editor's edit mode), and do not align to a pixel-art grid.
|
|
249
|
+
|
|
250
|
+
## game.assets: textures, preload and Assets Ready
|
|
251
|
+
|
|
252
|
+
Every `Game` owns an `assets: AssetLoader` — the engine's texture cache. `Sprite`, `AnimatedSprite` and `Tilemap` load through it: one fetch and one base texture per URL for the whole life of the Game, and every component gets its own clone of that base (three shares the GPU upload between clones with equal sampler parameters and reference-counts their disposal), so eight crates cost one image. A scene load never waits for images — `loadScene` stays synchronous — and **Assets Ready** is a promise beside it (ADR 0019):
|
|
253
|
+
|
|
254
|
+
```ts
|
|
255
|
+
game.registerSceneCatalog({ scenes, registry })
|
|
256
|
+
game.loadSceneByName('main')
|
|
257
|
+
await game.assets.ready() // every texture requested so far has loaded, or failed and been recorded
|
|
258
|
+
game.start()
|
|
259
|
+
|
|
260
|
+
await game.assets.preload(['waica:iso-crate', 'src/art/tree.png']) // ahead of a later scene
|
|
261
|
+
game.assets.status // { pending, loaded, failed } — a fresh object on every read
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
- **Cache rule.** Keyed by the URL as received: components pass what `resolveProps` resolved, `preload` resolves each uri through the registered scene catalog first (identity before `registerSceneCatalog`, and for a uri the resolver leaves unchanged). N requests of one URL call the backend once; `status.loaded` counts URLs, not requests. Keep-all: `unloadScene()`, `loadScene` and `loadSceneByName` never dispose, evict or reset an entry — `main → cave → main` re-downloads nothing — and only `game.dispose()` disposes every cached base, exactly once, leaving `status` at zeros. Components dispose only their own clone.
|
|
265
|
+
- **`ready()`.** Resolves the first time `pending` is 0 after the call: at once when nothing is pending, otherwise once every URL requested before or while waiting has settled. Readiness is relative to the moment it is awaited — a later spawn that requests new art reopens it until that art settles — and concurrent callers all resolve. It never rejects, and it has no timeout: a stalled image with no error event holds a host that awaits it (a Run Session is bounded by the MCP's session timeout instead).
|
|
266
|
+
- **`preload(uris)`.** Requests every uri and resolves once all of them settled, failures included. A component that later asks for the same resolved URL is a cache hit.
|
|
267
|
+
- **`status`.** `pending` is current; `loaded` and `failed` are cumulative for the Game and only grow until `dispose()`. A host that wants a loading bar reads it from `game.onUpdate`.
|
|
268
|
+
- **Failure rule.** A texture that fails to load is recorded, not thrown: `console.warn('[waica] assets: failed to load "<url>"', error)` once per Game per URL, `failed` counts it, `ready()` resolves, and every consumer falls back to its flat material — `Sprite` and `Tilemap` drop the failed map, dispose their clone and render their `color` again; `AnimatedSprite` never installs the failed sheet's clone, so that sheet's frames show a plain white quad. Asking for that URL again on the same Game is a cached failure — no retry, no second warning.
|
|
269
|
+
- **`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.
|
|
270
|
+
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import { type TextureBackend } from './texture-backend.js';
|
|
3
|
+
/** `game.assets.status`: a fresh snapshot of the cache's counters, cumulative for the Game. */
|
|
4
|
+
export interface AssetStatus {
|
|
5
|
+
/** Requested URLs that have not settled yet. */
|
|
6
|
+
pending: number;
|
|
7
|
+
/** Settled URLs whose image arrived. */
|
|
8
|
+
loaded: number;
|
|
9
|
+
/** Settled URLs whose load failed — recorded, warned once, never retried. */
|
|
10
|
+
failed: number;
|
|
11
|
+
}
|
|
12
|
+
export type TextureOutcome = 'loaded' | 'failed';
|
|
13
|
+
/** What a consumer gets from `game.assets.texture(url)`: its own clone, and when the image settled. */
|
|
14
|
+
export interface TextureHandle {
|
|
15
|
+
texture: THREE.Texture;
|
|
16
|
+
settled: Promise<TextureOutcome>;
|
|
17
|
+
}
|
|
18
|
+
export interface AssetLoaderOptions {
|
|
19
|
+
/** Replaces the real `THREE.TextureLoader` implementation (ADR 0013); defaults to it. */
|
|
20
|
+
backend?: TextureBackend;
|
|
21
|
+
/**
|
|
22
|
+
* Resolves a uri for `preload()` the way the scene loader resolves every
|
|
23
|
+
* prefab's string prop (`resolveProps` in scene.ts). Looked up on every
|
|
24
|
+
* call: `Game` wires this to its registered scene catalog, which survives
|
|
25
|
+
* `unloadScene()`. Defaults to identity, so an already-resolved or
|
|
26
|
+
* unresolvable uri passes through unchanged either way.
|
|
27
|
+
*/
|
|
28
|
+
resolveAsset?: (uri: string) => string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The engine's texture cache (`game.assets`, ADR 0019): keep-all for the
|
|
32
|
+
* Game's whole life, keyed by the URL as received, one backend load and one
|
|
33
|
+
* base texture per URL. `ready()` is the **Assets Ready** signal — a
|
|
34
|
+
* promise beside the synchronous scene load — and a failed image is
|
|
35
|
+
* recorded, not thrown, so it never breaks a host or a Run Session that
|
|
36
|
+
* awaits it. `unloadScene()` never touches this cache; `dispose()` empties it.
|
|
37
|
+
*/
|
|
38
|
+
export declare class AssetLoader {
|
|
39
|
+
private readonly backend;
|
|
40
|
+
private readonly resolveAsset;
|
|
41
|
+
private readonly entries;
|
|
42
|
+
private pendingCount;
|
|
43
|
+
private loadedCount;
|
|
44
|
+
private failedCount;
|
|
45
|
+
constructor(options?: AssetLoaderOptions);
|
|
46
|
+
/** A fresh object on every read: `pending` is current, `loaded` and `failed` only grow until `dispose()`. */
|
|
47
|
+
get status(): AssetStatus;
|
|
48
|
+
/**
|
|
49
|
+
* The consumer entry (Sprite, AnimatedSprite, Tilemap): requests `url`
|
|
50
|
+
* — already resolved by `resolveProps` — and returns a clone of its base
|
|
51
|
+
* right away, so a mesh exists before the image arrives, plus the
|
|
52
|
+
* settlement of that URL, which also fires on a cache hit.
|
|
53
|
+
*/
|
|
54
|
+
texture(url: string): TextureHandle;
|
|
55
|
+
/**
|
|
56
|
+
* Requests every uri ahead of time, each resolved through the registered
|
|
57
|
+
* scene catalog. Resolves once all of them settled, failures included —
|
|
58
|
+
* a failure still only warns once, the same as a component's request.
|
|
59
|
+
*/
|
|
60
|
+
preload(uris: string[]): Promise<void>;
|
|
61
|
+
/**
|
|
62
|
+
* Assets Ready: resolves the first time `pending` is 0 after the call —
|
|
63
|
+
* at once when nothing is pending, otherwise once every URL requested
|
|
64
|
+
* before or while waiting has settled. Never rejects. Resolves after the
|
|
65
|
+
* `settled` continuations consumers chained before the call, so a host
|
|
66
|
+
* that does `await game.assets.ready()` then `game.start()` renders its
|
|
67
|
+
* first frame with the textures in place.
|
|
68
|
+
*/
|
|
69
|
+
ready(): Promise<void>;
|
|
70
|
+
/** Disposes every cached base exactly once and forgets everything; called by `Game.dispose()`. */
|
|
71
|
+
dispose(): void;
|
|
72
|
+
private request;
|
|
73
|
+
private load;
|
|
74
|
+
/** One backend load as an outcome, never a rejection — a backend that throws synchronously included. */
|
|
75
|
+
private attempt;
|
|
76
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import { ThreeTextureBackend } from './texture-backend.js';
|
|
3
|
+
/**
|
|
4
|
+
* Not `base.clone()`: `Texture.copy` sets `needsUpdate`, which bumps the
|
|
5
|
+
* shared Source's version — a GPU re-upload of the same image for every
|
|
6
|
+
* clone — and, while the image is still missing, makes the renderer warn
|
|
7
|
+
* on every frame. Sharing the Source and mirroring `version` instead marks
|
|
8
|
+
* the clone uploadable exactly when the base is, and three then keeps one
|
|
9
|
+
* GPU texture per (Source, sampler parameters), reference-counted across
|
|
10
|
+
* clones (WebGLTextures). Colour space is the base's; filters, repeat and
|
|
11
|
+
* offset are the clone's own.
|
|
12
|
+
*/
|
|
13
|
+
function cloneOf(base) {
|
|
14
|
+
const clone = new THREE.Texture();
|
|
15
|
+
clone.source = base.source;
|
|
16
|
+
clone.colorSpace = base.colorSpace;
|
|
17
|
+
clone.version = base.version;
|
|
18
|
+
return clone;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The engine's texture cache (`game.assets`, ADR 0019): keep-all for the
|
|
22
|
+
* Game's whole life, keyed by the URL as received, one backend load and one
|
|
23
|
+
* base texture per URL. `ready()` is the **Assets Ready** signal — a
|
|
24
|
+
* promise beside the synchronous scene load — and a failed image is
|
|
25
|
+
* recorded, not thrown, so it never breaks a host or a Run Session that
|
|
26
|
+
* awaits it. `unloadScene()` never touches this cache; `dispose()` empties it.
|
|
27
|
+
*/
|
|
28
|
+
export class AssetLoader {
|
|
29
|
+
backend;
|
|
30
|
+
resolveAsset;
|
|
31
|
+
entries = new Map();
|
|
32
|
+
pendingCount = 0;
|
|
33
|
+
loadedCount = 0;
|
|
34
|
+
failedCount = 0;
|
|
35
|
+
constructor(options = {}) {
|
|
36
|
+
this.backend = options.backend ?? new ThreeTextureBackend();
|
|
37
|
+
this.resolveAsset = options.resolveAsset ?? ((uri) => uri);
|
|
38
|
+
}
|
|
39
|
+
/** A fresh object on every read: `pending` is current, `loaded` and `failed` only grow until `dispose()`. */
|
|
40
|
+
get status() {
|
|
41
|
+
return { pending: this.pendingCount, loaded: this.loadedCount, failed: this.failedCount };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The consumer entry (Sprite, AnimatedSprite, Tilemap): requests `url`
|
|
45
|
+
* — already resolved by `resolveProps` — and returns a clone of its base
|
|
46
|
+
* right away, so a mesh exists before the image arrives, plus the
|
|
47
|
+
* settlement of that URL, which also fires on a cache hit.
|
|
48
|
+
*/
|
|
49
|
+
texture(url) {
|
|
50
|
+
const entry = this.request(url);
|
|
51
|
+
const texture = cloneOf(entry.base);
|
|
52
|
+
if (entry.outcome === null) {
|
|
53
|
+
// Chained here, before the consumer chains its own continuation on
|
|
54
|
+
// `settled`, so the clone is uploadable by the time that one runs.
|
|
55
|
+
void entry.settled.then((outcome) => {
|
|
56
|
+
if (outcome === 'loaded')
|
|
57
|
+
texture.version = entry.base.version;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return { texture, settled: entry.settled };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Requests every uri ahead of time, each resolved through the registered
|
|
64
|
+
* scene catalog. Resolves once all of them settled, failures included —
|
|
65
|
+
* a failure still only warns once, the same as a component's request.
|
|
66
|
+
*/
|
|
67
|
+
async preload(uris) {
|
|
68
|
+
await Promise.all(uris.map((uri) => this.request(this.resolveAsset(uri)).settled));
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Assets Ready: resolves the first time `pending` is 0 after the call —
|
|
72
|
+
* at once when nothing is pending, otherwise once every URL requested
|
|
73
|
+
* before or while waiting has settled. Never rejects. Resolves after the
|
|
74
|
+
* `settled` continuations consumers chained before the call, so a host
|
|
75
|
+
* that does `await game.assets.ready()` then `game.start()` renders its
|
|
76
|
+
* first frame with the textures in place.
|
|
77
|
+
*/
|
|
78
|
+
async ready() {
|
|
79
|
+
while (this.pendingCount > 0) {
|
|
80
|
+
const inFlight = [...this.entries.values()].filter((entry) => entry.outcome === null);
|
|
81
|
+
await Promise.all(inFlight.map((entry) => entry.settled));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Disposes every cached base exactly once and forgets everything; called by `Game.dispose()`. */
|
|
85
|
+
dispose() {
|
|
86
|
+
for (const entry of this.entries.values())
|
|
87
|
+
entry.base.dispose();
|
|
88
|
+
this.entries.clear();
|
|
89
|
+
this.pendingCount = 0;
|
|
90
|
+
this.loadedCount = 0;
|
|
91
|
+
this.failedCount = 0;
|
|
92
|
+
}
|
|
93
|
+
request(url) {
|
|
94
|
+
const existing = this.entries.get(url);
|
|
95
|
+
if (existing)
|
|
96
|
+
return existing;
|
|
97
|
+
const base = new THREE.Texture();
|
|
98
|
+
base.colorSpace = THREE.SRGBColorSpace;
|
|
99
|
+
const entry = { base, outcome: null, settled: Promise.resolve('failed') };
|
|
100
|
+
// Registered and counted before the backend runs, so a backend that
|
|
101
|
+
// throws synchronously still settles this very entry.
|
|
102
|
+
this.entries.set(url, entry);
|
|
103
|
+
this.pendingCount += 1;
|
|
104
|
+
entry.settled = this.load(url, entry);
|
|
105
|
+
return entry;
|
|
106
|
+
}
|
|
107
|
+
async load(url, entry) {
|
|
108
|
+
const attempt = await this.attempt(url);
|
|
109
|
+
// A dispose() in flight already forgot this entry and disposed its base:
|
|
110
|
+
// nothing to write, warn about or count. Only the settlement is still
|
|
111
|
+
// owed, so a consumer awaiting it never hangs; it reports what the
|
|
112
|
+
// backend did.
|
|
113
|
+
if (this.entries.get(url) !== entry)
|
|
114
|
+
return attempt.outcome;
|
|
115
|
+
if (attempt.outcome === 'loaded') {
|
|
116
|
+
// The image lands on the base's Source, which every clone shares.
|
|
117
|
+
entry.base.image = attempt.texture.image;
|
|
118
|
+
entry.base.needsUpdate = true;
|
|
119
|
+
this.loadedCount += 1;
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
console.warn(`[waica] assets: failed to load "${url}"`, attempt.error);
|
|
123
|
+
this.failedCount += 1;
|
|
124
|
+
}
|
|
125
|
+
entry.outcome = attempt.outcome;
|
|
126
|
+
this.pendingCount -= 1;
|
|
127
|
+
return attempt.outcome;
|
|
128
|
+
}
|
|
129
|
+
/** One backend load as an outcome, never a rejection — a backend that throws synchronously included. */
|
|
130
|
+
async attempt(url) {
|
|
131
|
+
try {
|
|
132
|
+
return { outcome: 'loaded', texture: await this.backend.load(url) };
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
return { outcome: 'failed', error };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
/**
|
|
3
|
+
* The seam between `game.assets` and the browser (ADR 0013's move, applied
|
|
4
|
+
* to textures — ADR 0019). `load` resolves once the image behind `url` is
|
|
5
|
+
* decoded, with a texture whose `image` the loader adopts into its own
|
|
6
|
+
* cached base; it rejects on a load error and never throws synchronously.
|
|
7
|
+
* `happy-dom` decodes no images, so a test injects `FakeTextureBackend`
|
|
8
|
+
* (assets/test-helpers.ts) through `GameOptions.textures`.
|
|
9
|
+
*/
|
|
10
|
+
export interface TextureBackend {
|
|
11
|
+
load(url: string): Promise<THREE.Texture>;
|
|
12
|
+
}
|
|
13
|
+
/** The real implementation: one `THREE.TextureLoader` per Game, wrapped in a promise. */
|
|
14
|
+
export declare class ThreeTextureBackend implements TextureBackend {
|
|
15
|
+
private readonly loader;
|
|
16
|
+
load(url: string): Promise<THREE.Texture>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
/** The real implementation: one `THREE.TextureLoader` per Game, wrapped in a promise. */
|
|
3
|
+
export class ThreeTextureBackend {
|
|
4
|
+
loader = new THREE.TextureLoader();
|
|
5
|
+
load(url) {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
try {
|
|
8
|
+
this.loader.load(url, resolve, undefined, reject);
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
reject(error);
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { ViewportRect } from './anchored-pieces.js';
|
|
2
|
+
import { type EasingName } from './game-time.js';
|
|
3
|
+
/** A Camera Effect color: a name or a six-digit hex, case-insensitive. */
|
|
4
|
+
export type CameraEffectColor = 'black' | 'white' | `#${string}`;
|
|
5
|
+
export interface ShakeOptions {
|
|
6
|
+
/** The largest offset, in world units, on each axis. Finite and >= 0. */
|
|
7
|
+
intensity: number;
|
|
8
|
+
/** Seconds of Game Time until the offset reaches 0. Finite and >= 0. */
|
|
9
|
+
seconds: number;
|
|
10
|
+
/** How the amplitude decays from `intensity` to 0; default `'linear'`. */
|
|
11
|
+
easing?: EasingName;
|
|
12
|
+
}
|
|
13
|
+
export interface FadeOptions {
|
|
14
|
+
/** The color to carry the view to (opacity 1), or `'clear'` (opacity 0, color kept). */
|
|
15
|
+
to: CameraEffectColor | 'clear';
|
|
16
|
+
/** Seconds of Game Time from the current opacity to the target. Finite and >= 0. */
|
|
17
|
+
seconds: number;
|
|
18
|
+
easing?: EasingName;
|
|
19
|
+
}
|
|
20
|
+
export interface FlashOptions {
|
|
21
|
+
color: CameraEffectColor;
|
|
22
|
+
/** Seconds of Game Time from opacity 1 back to 0. Finite and >= 0. */
|
|
23
|
+
seconds: number;
|
|
24
|
+
}
|
|
25
|
+
/** What `shake`, `fade` and `flash` hand back. */
|
|
26
|
+
export interface CameraEffectHandle {
|
|
27
|
+
/** Stops the effect where it is. Idempotent; resolves `done` with false. */
|
|
28
|
+
cancel(): void;
|
|
29
|
+
/** True once the effect completed; false if cancelled, replaced, ended by its scope or invalid. */
|
|
30
|
+
readonly done: Promise<boolean>;
|
|
31
|
+
/**
|
|
32
|
+
* True once a *later* call to the same kind of effect has taken over
|
|
33
|
+
* before this one ended on its own — false while it is still running,
|
|
34
|
+
* and false once it ends on its own accord (completes, is cancelled, or
|
|
35
|
+
* ends by its scope) with nothing else in its place. A Shake never
|
|
36
|
+
* supersedes another (CA-7: overlapping shakes coexist), so this is
|
|
37
|
+
* always false for a Shake handle. Unlike `done`, this reads
|
|
38
|
+
* synchronously: a Promise only notifies its `.then()` in a microtask,
|
|
39
|
+
* which can land well after a caller scheduled on Game Time needs the
|
|
40
|
+
* answer on an exact Simulation Step (PR #98 review, `scene-
|
|
41
|
+
* transition.ts`).
|
|
42
|
+
*/
|
|
43
|
+
readonly superseded: boolean;
|
|
44
|
+
}
|
|
45
|
+
/** The effects' state after the last completed Simulation Step (read-only copy). */
|
|
46
|
+
export interface CameraEffectsState {
|
|
47
|
+
/** The offset, in world units, added to the camera only while drawing. */
|
|
48
|
+
shake: {
|
|
49
|
+
x: number;
|
|
50
|
+
y: number;
|
|
51
|
+
};
|
|
52
|
+
fade: {
|
|
53
|
+
color: string;
|
|
54
|
+
opacity: number;
|
|
55
|
+
};
|
|
56
|
+
flash: {
|
|
57
|
+
color: string;
|
|
58
|
+
opacity: number;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
export interface CameraEffectsOptions {
|
|
62
|
+
/** Where the Fade and Flash layers mount (the canvas's parent); resolved lazily. */
|
|
63
|
+
host: () => HTMLElement;
|
|
64
|
+
/** One screen pixel in world units under a fixed resolution, or null (no snap). */
|
|
65
|
+
pixel: () => number | null;
|
|
66
|
+
}
|
|
67
|
+
declare const ADVANCE: unique symbol;
|
|
68
|
+
declare const LAYOUT: unique symbol;
|
|
69
|
+
/**
|
|
70
|
+
* Screen-level Camera Effects (issue #74, ADR 0020): Shake, Fade and Flash,
|
|
71
|
+
* beside `game.camera` and never written into it. They advance only inside
|
|
72
|
+
* a Simulation Step, right after the scene camera (spec inference 9), so
|
|
73
|
+
* nothing moves while the Game is not simulating or a Run Session is
|
|
74
|
+
* paused. A Shake is an offset the Game adds to the camera only while it
|
|
75
|
+
* draws; a Fade and a Flash are HTML layers over the letterboxed game
|
|
76
|
+
* viewport, above the UI overlay. A Fade is session-scoped (it survives a
|
|
77
|
+
* scene change, ADR 0011); a Shake or Flash dies with its scene.
|
|
78
|
+
*/
|
|
79
|
+
export declare class CameraEffects {
|
|
80
|
+
private readonly options;
|
|
81
|
+
private steps;
|
|
82
|
+
private readonly shakes;
|
|
83
|
+
private offset;
|
|
84
|
+
private fadeRun;
|
|
85
|
+
private flashRun;
|
|
86
|
+
private readonly fadeLayer;
|
|
87
|
+
private readonly flashLayer;
|
|
88
|
+
private rect;
|
|
89
|
+
constructor(options: CameraEffectsOptions);
|
|
90
|
+
/** A fresh copy of the effects after the last completed Simulation Step. */
|
|
91
|
+
get state(): CameraEffectsState;
|
|
92
|
+
/**
|
|
93
|
+
* Jitters the drawn view by up to `intensity` world units per axis,
|
|
94
|
+
* decaying to 0 over `seconds`. Overlapping shakes apply the largest
|
|
95
|
+
* current amplitude. Not re-clamped to the scene camera's limits; snapped
|
|
96
|
+
* to whole screen pixels under a fixed resolution.
|
|
97
|
+
*/
|
|
98
|
+
shake(options: ShakeOptions): CameraEffectHandle;
|
|
99
|
+
/**
|
|
100
|
+
* Carries the Fade layer from its current opacity to 1 in `to`'s color,
|
|
101
|
+
* or to 0 with `'clear'`, over `seconds`; the result then holds. A new
|
|
102
|
+
* fade cancels a running one (its `done` resolves false).
|
|
103
|
+
*/
|
|
104
|
+
fade(options: FadeOptions): CameraEffectHandle;
|
|
105
|
+
/**
|
|
106
|
+
* Shows the Flash layer at opacity 1 in `color` and returns it to exactly
|
|
107
|
+
* 0 over `seconds`, independent of (and drawn above) the Fade. A new flash
|
|
108
|
+
* replaces a running one.
|
|
109
|
+
*/
|
|
110
|
+
flash(options: FlashOptions): CameraEffectHandle;
|
|
111
|
+
/**
|
|
112
|
+
* Ends every Shake and Flash (their `done` resolves false) and keeps the
|
|
113
|
+
* Fade — opacity, color and any running progress. Called by
|
|
114
|
+
* `Game.unloadScene()`.
|
|
115
|
+
*/
|
|
116
|
+
unloadScene(): void;
|
|
117
|
+
/** Ends every effect and removes the layers (`Game.dispose()`). */
|
|
118
|
+
dispose(): void;
|
|
119
|
+
/** Engine-internal: see advanceCameraEffects. */
|
|
120
|
+
[ADVANCE](): void;
|
|
121
|
+
/** Engine-internal: see layoutCameraEffects. */
|
|
122
|
+
[LAYOUT](rect: ViewportRect): void;
|
|
123
|
+
/** Normalized progress of a timed effect after the current step: 1 at (and past) its end. */
|
|
124
|
+
private progress;
|
|
125
|
+
private advanceShakes;
|
|
126
|
+
/** Mirrors both layers' state onto their elements, mounting them on first need. */
|
|
127
|
+
private draw;
|
|
128
|
+
private mount;
|
|
129
|
+
/** Sizes a layer to the game viewport (the whole host until the Game lays it out). */
|
|
130
|
+
private place;
|
|
131
|
+
private invalid;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Engine-internal: advances every Camera Effect by exactly one Simulation
|
|
135
|
+
* Step. `Game.simulateStep()` calls it right after the scene camera, so
|
|
136
|
+
* nothing advances while the Game is not simulating. Not re-exported from
|
|
137
|
+
* the package entry.
|
|
138
|
+
*/
|
|
139
|
+
export declare function advanceCameraEffects(effects: CameraEffects): void;
|
|
140
|
+
/** Engine-internal: sizes the Fade and Flash layers to the game viewport (`Game.resize()`). */
|
|
141
|
+
export declare function layoutCameraEffects(effects: CameraEffects, rect: ViewportRect): void;
|
|
142
|
+
export {};
|