@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
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { SIMULATION_STEP, SIMULATION_TIME_EPSILON } from './fixed-step.js';
|
|
2
|
+
import { resolveEasing } from './game-time.js';
|
|
3
|
+
const COLORS = { black: '#000000', white: '#ffffff' };
|
|
4
|
+
/** Above the UI overlay (`ui.ts`, z-index 9000): a Fade covers the HUD too. */
|
|
5
|
+
const FADE_Z = 9001;
|
|
6
|
+
const FLASH_Z = 9002;
|
|
7
|
+
const ADVANCE = Symbol('waica.cameraEffects.advance');
|
|
8
|
+
const LAYOUT = Symbol('waica.cameraEffects.layout');
|
|
9
|
+
function normalizeColor(value) {
|
|
10
|
+
if (typeof value !== 'string')
|
|
11
|
+
return null;
|
|
12
|
+
if (Object.prototype.hasOwnProperty.call(COLORS, value))
|
|
13
|
+
return COLORS[value];
|
|
14
|
+
return /^#[0-9a-fA-F]{6}$/.test(value) ? value.toLowerCase() : null;
|
|
15
|
+
}
|
|
16
|
+
function validSeconds(value) {
|
|
17
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
|
18
|
+
}
|
|
19
|
+
function easingOf(value) {
|
|
20
|
+
if (value !== undefined && typeof value !== 'string')
|
|
21
|
+
return null;
|
|
22
|
+
return resolveEasing(value);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Integer-only hash of (shake creation step, current step, axis) into
|
|
26
|
+
* [0, 1): the same bits in Node and every browser (spec inference 3), so a
|
|
27
|
+
* Run Session reproduces a shake frame for frame. No global RNG.
|
|
28
|
+
*/
|
|
29
|
+
function jitter(seed, step, axis) {
|
|
30
|
+
let h = (Math.imul(seed + 1, 0x9e3779b1) ^ Math.imul(step + 1, 0x85ebca77) ^ Math.imul(axis + 1, 0xc2b2ae3d)) >>> 0;
|
|
31
|
+
h = Math.imul(h ^ (h >>> 16), 0x7feb352d) >>> 0;
|
|
32
|
+
h = Math.imul(h ^ (h >>> 15), 0x846ca68b) >>> 0;
|
|
33
|
+
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
|
|
34
|
+
}
|
|
35
|
+
function settledHandle(done, cancel, superseded) {
|
|
36
|
+
return {
|
|
37
|
+
cancel,
|
|
38
|
+
done,
|
|
39
|
+
get superseded() {
|
|
40
|
+
return superseded();
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Screen-level Camera Effects (issue #74, ADR 0020): Shake, Fade and Flash,
|
|
46
|
+
* beside `game.camera` and never written into it. They advance only inside
|
|
47
|
+
* a Simulation Step, right after the scene camera (spec inference 9), so
|
|
48
|
+
* nothing moves while the Game is not simulating or a Run Session is
|
|
49
|
+
* paused. A Shake is an offset the Game adds to the camera only while it
|
|
50
|
+
* draws; a Fade and a Flash are HTML layers over the letterboxed game
|
|
51
|
+
* viewport, above the UI overlay. A Fade is session-scoped (it survives a
|
|
52
|
+
* scene change, ADR 0011); a Shake or Flash dies with its scene.
|
|
53
|
+
*/
|
|
54
|
+
export class CameraEffects {
|
|
55
|
+
options;
|
|
56
|
+
steps = 0;
|
|
57
|
+
shakes = [];
|
|
58
|
+
offset = { x: 0, y: 0 };
|
|
59
|
+
fadeRun = null;
|
|
60
|
+
flashRun = null;
|
|
61
|
+
fadeLayer = { color: '#000000', opacity: 0, element: null };
|
|
62
|
+
flashLayer = { color: '#ffffff', opacity: 0, element: null };
|
|
63
|
+
rect = null;
|
|
64
|
+
constructor(options) {
|
|
65
|
+
this.options = options;
|
|
66
|
+
}
|
|
67
|
+
/** A fresh copy of the effects after the last completed Simulation Step. */
|
|
68
|
+
get state() {
|
|
69
|
+
return {
|
|
70
|
+
shake: { ...this.offset },
|
|
71
|
+
fade: { color: this.fadeLayer.color, opacity: this.fadeLayer.opacity },
|
|
72
|
+
flash: { color: this.flashLayer.color, opacity: this.flashLayer.opacity },
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Jitters the drawn view by up to `intensity` world units per axis,
|
|
77
|
+
* decaying to 0 over `seconds`. Overlapping shakes apply the largest
|
|
78
|
+
* current amplitude. Not re-clamped to the scene camera's limits; snapped
|
|
79
|
+
* to whole screen pixels under a fixed resolution.
|
|
80
|
+
*/
|
|
81
|
+
shake(options) {
|
|
82
|
+
const { intensity, seconds } = options;
|
|
83
|
+
if (!validSeconds(intensity))
|
|
84
|
+
return this.invalid('shake(): intensity must be a finite number >= 0');
|
|
85
|
+
if (!validSeconds(seconds))
|
|
86
|
+
return this.invalid('shake(): seconds must be a finite number >= 0');
|
|
87
|
+
const ease = easingOf(options.easing);
|
|
88
|
+
if (!ease)
|
|
89
|
+
return this.invalid('shake(): easing must be a known easing name');
|
|
90
|
+
let resolve = () => { };
|
|
91
|
+
const done = new Promise((r) => (resolve = r));
|
|
92
|
+
const entry = { start: this.steps, seconds, ease, resolve, intensity, superseded: false };
|
|
93
|
+
this.shakes.push(entry);
|
|
94
|
+
return settledHandle(done, () => {
|
|
95
|
+
const index = this.shakes.indexOf(entry);
|
|
96
|
+
if (index === -1)
|
|
97
|
+
return;
|
|
98
|
+
this.shakes.splice(index, 1);
|
|
99
|
+
entry.resolve(false);
|
|
100
|
+
},
|
|
101
|
+
// Never superseded (CA-7): overlapping shakes coexist, so nothing
|
|
102
|
+
// ever sets a Shake entry's flag; it stays false for its own life.
|
|
103
|
+
() => entry.superseded);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Carries the Fade layer from its current opacity to 1 in `to`'s color,
|
|
107
|
+
* or to 0 with `'clear'`, over `seconds`; the result then holds. A new
|
|
108
|
+
* fade cancels a running one (its `done` resolves false).
|
|
109
|
+
*/
|
|
110
|
+
fade(options) {
|
|
111
|
+
const clear = options.to === 'clear';
|
|
112
|
+
const color = clear ? this.fadeLayer.color : normalizeColor(options.to);
|
|
113
|
+
if (!color)
|
|
114
|
+
return this.invalid(`fade(): unknown color "${String(options.to)}"; use 'black', 'white', '#rrggbb' or 'clear'`);
|
|
115
|
+
if (!validSeconds(options.seconds))
|
|
116
|
+
return this.invalid('fade(): seconds must be a finite number >= 0');
|
|
117
|
+
const ease = easingOf(options.easing);
|
|
118
|
+
if (!ease)
|
|
119
|
+
return this.invalid('fade(): easing must be a known easing name');
|
|
120
|
+
// Marks the outgoing fade superseded (not just cancelled) before
|
|
121
|
+
// resolving `done` false: a caller checking `.superseded` later must
|
|
122
|
+
// see this, whatever happens to the layer afterwards (PR #98 review).
|
|
123
|
+
if (this.fadeRun)
|
|
124
|
+
this.fadeRun.superseded = true;
|
|
125
|
+
this.fadeRun?.resolve(false);
|
|
126
|
+
let resolve = () => { };
|
|
127
|
+
const done = new Promise((r) => (resolve = r));
|
|
128
|
+
const entry = {
|
|
129
|
+
start: this.steps,
|
|
130
|
+
seconds: options.seconds,
|
|
131
|
+
ease,
|
|
132
|
+
resolve,
|
|
133
|
+
from: this.fadeLayer.opacity,
|
|
134
|
+
to: clear ? 0 : 1,
|
|
135
|
+
superseded: false,
|
|
136
|
+
};
|
|
137
|
+
this.fadeRun = entry;
|
|
138
|
+
this.fadeLayer.color = color;
|
|
139
|
+
this.draw();
|
|
140
|
+
return settledHandle(done, () => {
|
|
141
|
+
if (this.fadeRun !== entry)
|
|
142
|
+
return;
|
|
143
|
+
this.fadeRun = null;
|
|
144
|
+
entry.resolve(false);
|
|
145
|
+
}, () => entry.superseded);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Shows the Flash layer at opacity 1 in `color` and returns it to exactly
|
|
149
|
+
* 0 over `seconds`, independent of (and drawn above) the Fade. A new flash
|
|
150
|
+
* replaces a running one.
|
|
151
|
+
*/
|
|
152
|
+
flash(options) {
|
|
153
|
+
const color = normalizeColor(options.color);
|
|
154
|
+
if (!color)
|
|
155
|
+
return this.invalid(`flash(): unknown color "${String(options.color)}"; use 'black', 'white' or '#rrggbb'`);
|
|
156
|
+
if (!validSeconds(options.seconds))
|
|
157
|
+
return this.invalid('flash(): seconds must be a finite number >= 0');
|
|
158
|
+
if (this.flashRun)
|
|
159
|
+
this.flashRun.superseded = true;
|
|
160
|
+
this.flashRun?.resolve(false);
|
|
161
|
+
let resolve = () => { };
|
|
162
|
+
const done = new Promise((r) => (resolve = r));
|
|
163
|
+
const entry = { start: this.steps, seconds: options.seconds, ease: (t) => t, resolve, superseded: false };
|
|
164
|
+
this.flashRun = entry;
|
|
165
|
+
this.flashLayer.color = color;
|
|
166
|
+
this.flashLayer.opacity = 1;
|
|
167
|
+
this.draw();
|
|
168
|
+
return settledHandle(done, () => {
|
|
169
|
+
if (this.flashRun !== entry)
|
|
170
|
+
return;
|
|
171
|
+
this.flashRun = null;
|
|
172
|
+
this.flashLayer.opacity = 0;
|
|
173
|
+
this.draw();
|
|
174
|
+
entry.resolve(false);
|
|
175
|
+
}, () => entry.superseded);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Ends every Shake and Flash (their `done` resolves false) and keeps the
|
|
179
|
+
* Fade — opacity, color and any running progress. Called by
|
|
180
|
+
* `Game.unloadScene()`.
|
|
181
|
+
*/
|
|
182
|
+
unloadScene() {
|
|
183
|
+
for (const shake of this.shakes.splice(0))
|
|
184
|
+
shake.resolve(false);
|
|
185
|
+
this.offset = { x: 0, y: 0 };
|
|
186
|
+
this.flashRun?.resolve(false);
|
|
187
|
+
this.flashRun = null;
|
|
188
|
+
this.flashLayer.opacity = 0;
|
|
189
|
+
this.draw();
|
|
190
|
+
}
|
|
191
|
+
/** Ends every effect and removes the layers (`Game.dispose()`). */
|
|
192
|
+
dispose() {
|
|
193
|
+
this.unloadScene();
|
|
194
|
+
this.fadeRun?.resolve(false);
|
|
195
|
+
this.fadeRun = null;
|
|
196
|
+
this.fadeLayer.color = '#000000';
|
|
197
|
+
this.fadeLayer.opacity = 0;
|
|
198
|
+
for (const layer of [this.fadeLayer, this.flashLayer]) {
|
|
199
|
+
layer.element?.remove();
|
|
200
|
+
layer.element = null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/** Engine-internal: see advanceCameraEffects. */
|
|
204
|
+
[ADVANCE]() {
|
|
205
|
+
this.steps += 1;
|
|
206
|
+
this.advanceShakes();
|
|
207
|
+
const fade = this.fadeRun;
|
|
208
|
+
if (fade) {
|
|
209
|
+
const t = this.progress(fade);
|
|
210
|
+
this.fadeLayer.opacity = t >= 1 ? fade.to : fade.from + (fade.to - fade.from) * fade.ease(t);
|
|
211
|
+
if (t >= 1) {
|
|
212
|
+
this.fadeRun = null;
|
|
213
|
+
fade.resolve(true);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const flash = this.flashRun;
|
|
217
|
+
if (flash) {
|
|
218
|
+
const t = this.progress(flash);
|
|
219
|
+
this.flashLayer.opacity = t >= 1 ? 0 : 1 - flash.ease(t);
|
|
220
|
+
if (t >= 1) {
|
|
221
|
+
this.flashRun = null;
|
|
222
|
+
flash.resolve(true);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
this.draw();
|
|
226
|
+
}
|
|
227
|
+
/** Engine-internal: see layoutCameraEffects. */
|
|
228
|
+
[LAYOUT](rect) {
|
|
229
|
+
this.rect = { ...rect };
|
|
230
|
+
for (const layer of [this.fadeLayer, this.flashLayer]) {
|
|
231
|
+
if (layer.element)
|
|
232
|
+
this.place(layer.element);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/** Normalized progress of a timed effect after the current step: 1 at (and past) its end. */
|
|
236
|
+
progress(entry) {
|
|
237
|
+
const elapsed = (this.steps - entry.start) * SIMULATION_STEP;
|
|
238
|
+
if (elapsed >= entry.seconds - SIMULATION_TIME_EPSILON)
|
|
239
|
+
return 1;
|
|
240
|
+
return elapsed / entry.seconds;
|
|
241
|
+
}
|
|
242
|
+
advanceShakes() {
|
|
243
|
+
let amplitude = 0;
|
|
244
|
+
let seed = 0;
|
|
245
|
+
for (const shake of [...this.shakes]) {
|
|
246
|
+
const t = this.progress(shake);
|
|
247
|
+
if (t >= 1) {
|
|
248
|
+
this.shakes.splice(this.shakes.indexOf(shake), 1);
|
|
249
|
+
shake.resolve(true);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const current = shake.intensity * (1 - shake.ease(t));
|
|
253
|
+
if (current > amplitude) {
|
|
254
|
+
amplitude = current;
|
|
255
|
+
seed = shake.start;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (amplitude <= 0) {
|
|
259
|
+
this.offset = { x: 0, y: 0 };
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const pixel = this.options.pixel();
|
|
263
|
+
const axis = (index) => {
|
|
264
|
+
const raw = amplitude * (2 * jitter(seed, this.steps, index) - 1);
|
|
265
|
+
// Truncating toward zero keeps a snapped offset inside the envelope.
|
|
266
|
+
const snapped = pixel ? Math.trunc(raw / pixel) * pixel : raw;
|
|
267
|
+
return snapped + 0;
|
|
268
|
+
};
|
|
269
|
+
this.offset = { x: axis(0), y: axis(1) };
|
|
270
|
+
}
|
|
271
|
+
/** Mirrors both layers' state onto their elements, mounting them on first need. */
|
|
272
|
+
draw() {
|
|
273
|
+
const needed = this.fadeLayer.opacity > 0 || this.flashLayer.opacity > 0;
|
|
274
|
+
if (needed && !this.fadeLayer.element)
|
|
275
|
+
this.mount();
|
|
276
|
+
for (const layer of [this.fadeLayer, this.flashLayer]) {
|
|
277
|
+
const element = layer.element;
|
|
278
|
+
if (!element)
|
|
279
|
+
continue;
|
|
280
|
+
element.style.backgroundColor = layer.color;
|
|
281
|
+
element.style.opacity = String(layer.opacity);
|
|
282
|
+
element.style.display = layer.opacity > 0 ? '' : 'none';
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
mount() {
|
|
286
|
+
const host = this.options.host();
|
|
287
|
+
if (getComputedStyle(host).position === 'static')
|
|
288
|
+
host.style.position = 'relative';
|
|
289
|
+
for (const [layer, name, z] of [
|
|
290
|
+
[this.fadeLayer, 'fade', FADE_Z],
|
|
291
|
+
[this.flashLayer, 'flash', FLASH_Z],
|
|
292
|
+
]) {
|
|
293
|
+
const element = document.createElement('div');
|
|
294
|
+
element.dataset.waicaCameraEffect = name;
|
|
295
|
+
element.style.position = 'absolute';
|
|
296
|
+
element.style.pointerEvents = 'none';
|
|
297
|
+
element.style.zIndex = String(z);
|
|
298
|
+
element.style.display = 'none';
|
|
299
|
+
this.place(element);
|
|
300
|
+
host.append(element);
|
|
301
|
+
layer.element = element;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
/** Sizes a layer to the game viewport (the whole host until the Game lays it out). */
|
|
305
|
+
place(element) {
|
|
306
|
+
const rect = this.rect;
|
|
307
|
+
if (!rect) {
|
|
308
|
+
element.style.inset = '0';
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
element.style.inset = '';
|
|
312
|
+
element.style.left = `${rect.x}px`;
|
|
313
|
+
element.style.top = `${rect.y}px`;
|
|
314
|
+
element.style.width = `${rect.width}px`;
|
|
315
|
+
element.style.height = `${rect.height}px`;
|
|
316
|
+
}
|
|
317
|
+
invalid(message) {
|
|
318
|
+
console.warn(`[waica] cameraEffects.${message}`);
|
|
319
|
+
return settledHandle(Promise.resolve(false), () => { }, () => false);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Engine-internal: advances every Camera Effect by exactly one Simulation
|
|
324
|
+
* Step. `Game.simulateStep()` calls it right after the scene camera, so
|
|
325
|
+
* nothing advances while the Game is not simulating. Not re-exported from
|
|
326
|
+
* the package entry.
|
|
327
|
+
*/
|
|
328
|
+
export function advanceCameraEffects(effects) {
|
|
329
|
+
effects[ADVANCE]();
|
|
330
|
+
}
|
|
331
|
+
/** Engine-internal: sizes the Fade and Flash layers to the game viewport (`Game.resize()`). */
|
|
332
|
+
export function layoutCameraEffects(effects, rect) {
|
|
333
|
+
effects[LAYOUT](rect);
|
|
334
|
+
}
|
|
@@ -6,10 +6,11 @@ import type { YSortParticipant } from '../render-sort.js';
|
|
|
6
6
|
* Sprite animated from one or more spritesheets. The main sheet is the
|
|
7
7
|
* top-level texture/cols/rows (or explicit cells); extraSheets append after
|
|
8
8
|
* it, and clip frames index the sheets consecutively (sheet 0 owns 0..n0-1,
|
|
9
|
-
* sheet 1 the next n1, …). Each instance
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* sheet 1 the next n1, …). Each instance owns its own clones of the cached
|
|
10
|
+
* sheets (game.assets, ADR 0019) to animate UVs independently. On sheets
|
|
11
|
+
* with explicit cells the frames vary in pixel size, so the quad rescales
|
|
12
|
+
* per frame, anchored bottom-center — width/height size the sheet's largest
|
|
13
|
+
* frame and smaller ones keep their feet planted.
|
|
13
14
|
*/
|
|
14
15
|
export declare class AnimatedSprite extends Component implements YSortParticipant {
|
|
15
16
|
static componentName: string;
|
|
@@ -91,6 +92,7 @@ export declare class AnimatedSprite extends Component implements YSortParticipan
|
|
|
91
92
|
private readonly player;
|
|
92
93
|
private sheets;
|
|
93
94
|
private texs;
|
|
95
|
+
private readonly failedSheets;
|
|
94
96
|
private mesh?;
|
|
95
97
|
private frame;
|
|
96
98
|
private frameScaleX;
|
|
@@ -100,6 +102,17 @@ export declare class AnimatedSprite extends Component implements YSortParticipan
|
|
|
100
102
|
play(name: string): void;
|
|
101
103
|
onUpdate(dt: number): void;
|
|
102
104
|
onDestroy(): void;
|
|
105
|
+
/**
|
|
106
|
+
* The sheet's own clone of the cached base. Non-uniform slicing needs
|
|
107
|
+
* the image's pixel size, so the current frame is re-applied once the
|
|
108
|
+
* sheet settles — on a cache hit too, whose settlement is already
|
|
109
|
+
* resolved — unless this sprite was destroyed meanwhile. A sheet that
|
|
110
|
+
* fails is remembered so the quad shows its flat material on that
|
|
111
|
+
* sheet's frames instead of sampling an image that never arrived (CA-4).
|
|
112
|
+
* An empty url (a sheet not authored yet) owns a bare texture and never
|
|
113
|
+
* touches game.assets, so it counts nowhere and warns about nothing.
|
|
114
|
+
*/
|
|
115
|
+
private sheetTexture;
|
|
103
116
|
private showFrame;
|
|
104
117
|
/** Repositions/rescales the displayed frame inside its anchored full-size box. */
|
|
105
118
|
private syncQuad;
|
|
@@ -3,16 +3,16 @@ import { Component } from '../component.js';
|
|
|
3
3
|
import { ClipPlayer } from '../animation/clip-player.js';
|
|
4
4
|
import { locateFrame, sheetCell } from '../animation/sheet.js';
|
|
5
5
|
import { spritePlacement } from '../sprite-placement.js';
|
|
6
|
-
const loader = new THREE.TextureLoader();
|
|
7
6
|
const clampAnchor = (value) => Math.min(1, Math.max(0, value));
|
|
8
7
|
/**
|
|
9
8
|
* Sprite animated from one or more spritesheets. The main sheet is the
|
|
10
9
|
* top-level texture/cols/rows (or explicit cells); extraSheets append after
|
|
11
10
|
* it, and clip frames index the sheets consecutively (sheet 0 owns 0..n0-1,
|
|
12
|
-
* sheet 1 the next n1, …). Each instance
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* sheet 1 the next n1, …). Each instance owns its own clones of the cached
|
|
12
|
+
* sheets (game.assets, ADR 0019) to animate UVs independently. On sheets
|
|
13
|
+
* with explicit cells the frames vary in pixel size, so the quad rescales
|
|
14
|
+
* per frame, anchored bottom-center — width/height size the sheet's largest
|
|
15
|
+
* frame and smaller ones keep their feet planted.
|
|
16
16
|
*/
|
|
17
17
|
export class AnimatedSprite extends Component {
|
|
18
18
|
static componentName = 'AnimatedSprite';
|
|
@@ -30,6 +30,7 @@ export class AnimatedSprite extends Component {
|
|
|
30
30
|
'player',
|
|
31
31
|
'sheets',
|
|
32
32
|
'texs',
|
|
33
|
+
'failedSheets',
|
|
33
34
|
'mesh',
|
|
34
35
|
'frame',
|
|
35
36
|
'frameScaleX',
|
|
@@ -135,6 +136,8 @@ export class AnimatedSprite extends Component {
|
|
|
135
136
|
player = new ClipPlayer();
|
|
136
137
|
sheets = [];
|
|
137
138
|
texs = [];
|
|
139
|
+
// Clones whose sheet failed to load: applyFrame never installs them (CA-4).
|
|
140
|
+
failedSheets = new Set();
|
|
138
141
|
mesh;
|
|
139
142
|
frame = 0;
|
|
140
143
|
// Current frame's quad scale relative to the sheet's largest cell: always
|
|
@@ -157,10 +160,7 @@ export class AnimatedSprite extends Component {
|
|
|
157
160
|
main.cells = this.cells;
|
|
158
161
|
this.sheets = [main, ...this.extraSheets];
|
|
159
162
|
this.texs = this.sheets.map((sheet) => {
|
|
160
|
-
|
|
161
|
-
// current frame once each texture is in.
|
|
162
|
-
const tex = loader.load(sheet.texture, () => this.applyFrame());
|
|
163
|
-
tex.colorSpace = THREE.SRGBColorSpace;
|
|
163
|
+
const tex = this.sheetTexture(sheet.texture);
|
|
164
164
|
if (this.pixelArt) {
|
|
165
165
|
tex.magFilter = THREE.NearestFilter;
|
|
166
166
|
tex.minFilter = THREE.NearestFilter;
|
|
@@ -195,8 +195,34 @@ export class AnimatedSprite extends Component {
|
|
|
195
195
|
this.mesh?.removeFromParent();
|
|
196
196
|
this.mesh?.geometry.dispose();
|
|
197
197
|
this.mesh?.material.dispose();
|
|
198
|
+
// Only this sprite's clones: the cached bases live with the Game.
|
|
198
199
|
for (const tex of this.texs)
|
|
199
200
|
tex.dispose();
|
|
201
|
+
this.texs = [];
|
|
202
|
+
this.failedSheets.clear();
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* The sheet's own clone of the cached base. Non-uniform slicing needs
|
|
206
|
+
* the image's pixel size, so the current frame is re-applied once the
|
|
207
|
+
* sheet settles — on a cache hit too, whose settlement is already
|
|
208
|
+
* resolved — unless this sprite was destroyed meanwhile. A sheet that
|
|
209
|
+
* fails is remembered so the quad shows its flat material on that
|
|
210
|
+
* sheet's frames instead of sampling an image that never arrived (CA-4).
|
|
211
|
+
* An empty url (a sheet not authored yet) owns a bare texture and never
|
|
212
|
+
* touches game.assets, so it counts nowhere and warns about nothing.
|
|
213
|
+
*/
|
|
214
|
+
sheetTexture(url) {
|
|
215
|
+
if (!url)
|
|
216
|
+
return new THREE.Texture();
|
|
217
|
+
const { texture, settled } = this.game.assets.texture(url);
|
|
218
|
+
void settled.then((outcome) => {
|
|
219
|
+
if (!this.texs.includes(texture))
|
|
220
|
+
return;
|
|
221
|
+
if (outcome === 'failed')
|
|
222
|
+
this.failedSheets.add(texture);
|
|
223
|
+
this.applyFrame();
|
|
224
|
+
});
|
|
225
|
+
return texture;
|
|
200
226
|
}
|
|
201
227
|
showFrame(index) {
|
|
202
228
|
this.frame = index;
|
|
@@ -227,9 +253,12 @@ export class AnimatedSprite extends Component {
|
|
|
227
253
|
const tex = this.texs[located.sheet];
|
|
228
254
|
if (!sheet || !tex)
|
|
229
255
|
return;
|
|
230
|
-
if (this.mesh
|
|
231
|
-
this.
|
|
232
|
-
this.mesh.material.
|
|
256
|
+
if (this.mesh) {
|
|
257
|
+
const map = this.failedSheets.has(tex) ? null : tex;
|
|
258
|
+
if (this.mesh.material.map !== map) {
|
|
259
|
+
this.mesh.material.map = map;
|
|
260
|
+
this.mesh.material.needsUpdate = true;
|
|
261
|
+
}
|
|
233
262
|
}
|
|
234
263
|
const cells = sheet.cells;
|
|
235
264
|
if (cells?.length) {
|
|
@@ -69,6 +69,12 @@ export declare class Sprite extends Component implements YSortParticipant {
|
|
|
69
69
|
private mesh?;
|
|
70
70
|
onReady(): void;
|
|
71
71
|
onDestroy(): void;
|
|
72
|
+
/**
|
|
73
|
+
* CA-4's failure rule: an image that never arrives leaves the flat
|
|
74
|
+
* `color`, not a white quad over an empty map. Skipped once the sprite
|
|
75
|
+
* was destroyed or the clone is no longer this material's map.
|
|
76
|
+
*/
|
|
77
|
+
private dropFailedTexture;
|
|
72
78
|
private syncQuad;
|
|
73
79
|
private createGeometry;
|
|
74
80
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import * as THREE from 'three';
|
|
2
2
|
import { Component } from '../component.js';
|
|
3
3
|
import { spritePlacement } from '../sprite-placement.js';
|
|
4
|
-
const loader = new THREE.TextureLoader();
|
|
5
4
|
const clampAnchor = (value) => Math.min(1, Math.max(0, value));
|
|
6
5
|
/**
|
|
7
6
|
* Textured or flat-color quad. In the unified pipeline, a 2D sprite is a
|
|
@@ -109,14 +108,20 @@ export class Sprite extends Component {
|
|
|
109
108
|
onReady() {
|
|
110
109
|
const material = new THREE.MeshBasicMaterial({ color: this.color, transparent: true });
|
|
111
110
|
if (this.texture) {
|
|
112
|
-
|
|
111
|
+
// Its own clone of the cached base (game.assets, ADR 0019): filters
|
|
112
|
+
// are per clone, colour space comes with the base, and the image lands
|
|
113
|
+
// on the Source every clone of this URL shares.
|
|
114
|
+
const { texture, settled } = this.game.assets.texture(this.texture);
|
|
113
115
|
if (this.pixelArt) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
+
texture.magFilter = THREE.NearestFilter;
|
|
117
|
+
texture.minFilter = THREE.NearestFilter;
|
|
116
118
|
}
|
|
117
|
-
|
|
118
|
-
material.map = tex;
|
|
119
|
+
material.map = texture;
|
|
119
120
|
material.color.set(0xffffff);
|
|
121
|
+
void settled.then((outcome) => {
|
|
122
|
+
if (outcome === 'failed')
|
|
123
|
+
this.dropFailedTexture(texture);
|
|
124
|
+
});
|
|
120
125
|
}
|
|
121
126
|
this.mesh = new THREE.Mesh(this.createGeometry(), material);
|
|
122
127
|
this.mesh.position.z = this.layer * 0.01;
|
|
@@ -126,7 +131,24 @@ export class Sprite extends Component {
|
|
|
126
131
|
onDestroy() {
|
|
127
132
|
this.mesh?.removeFromParent();
|
|
128
133
|
this.mesh?.geometry.dispose();
|
|
134
|
+
// Only this sprite's clone: the cached base lives with the Game.
|
|
135
|
+
this.mesh?.material.map?.dispose();
|
|
129
136
|
this.mesh?.material.dispose();
|
|
137
|
+
this.mesh = undefined;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* CA-4's failure rule: an image that never arrives leaves the flat
|
|
141
|
+
* `color`, not a white quad over an empty map. Skipped once the sprite
|
|
142
|
+
* was destroyed or the clone is no longer this material's map.
|
|
143
|
+
*/
|
|
144
|
+
dropFailedTexture(texture) {
|
|
145
|
+
const material = this.mesh?.material;
|
|
146
|
+
if (!material || material.map !== texture)
|
|
147
|
+
return;
|
|
148
|
+
material.map = null;
|
|
149
|
+
texture.dispose();
|
|
150
|
+
material.color.setHex(this.color);
|
|
151
|
+
material.needsUpdate = true;
|
|
130
152
|
}
|
|
131
153
|
syncQuad() {
|
|
132
154
|
if (!this.mesh)
|
|
@@ -107,6 +107,12 @@ export declare class Tilemap extends Component implements SolidSource {
|
|
|
107
107
|
private gridSpec;
|
|
108
108
|
private rebuildMap;
|
|
109
109
|
private rebuildMaterial;
|
|
110
|
+
/**
|
|
111
|
+
* CA-4's failure rule: an image that never arrives leaves the flat
|
|
112
|
+
* `color`, not a white map over an empty texture. Only reached while the
|
|
113
|
+
* failed clone is still the current one (see rebuildMaterial).
|
|
114
|
+
*/
|
|
115
|
+
private dropFailedTexture;
|
|
110
116
|
private makeMaterial;
|
|
111
117
|
private rebuildGeometry;
|
|
112
118
|
private rebuildSolids;
|
|
@@ -5,7 +5,6 @@ import { projectIsometric } from '../projection.js';
|
|
|
5
5
|
import { SOLID_SOURCE_SYMBOL } from '../scene-solids.js';
|
|
6
6
|
import { cellAt as gridCellAt, cellBounds as gridCellBounds, cellIndex as gridCellIndex, } from '../tilemap-grid.js';
|
|
7
7
|
import { Solid } from './solid.js';
|
|
8
|
-
const loader = new THREE.TextureLoader();
|
|
9
8
|
/** One authorable cell map rendered as a single merged geometry. */
|
|
10
9
|
export class Tilemap extends Component {
|
|
11
10
|
static componentName = 'Tilemap';
|
|
@@ -212,20 +211,45 @@ export class Tilemap extends Component {
|
|
|
212
211
|
if (!this.texture)
|
|
213
212
|
return;
|
|
214
213
|
const requested = this.texture;
|
|
215
|
-
|
|
216
|
-
|
|
214
|
+
// Its own clone of the cached base (game.assets, ADR 0019). The image's
|
|
215
|
+
// pixel size drives the UVs, so the geometry is rebuilt once the texture
|
|
216
|
+
// settles — on a cache hit too, whose settlement is already resolved —
|
|
217
|
+
// and a texture that fails is dropped for the flat colour; neither
|
|
218
|
+
// happens if the texture was replaced or the component destroyed
|
|
219
|
+
// meanwhile.
|
|
220
|
+
const { texture, settled } = this.game.assets.texture(requested);
|
|
221
|
+
void settled.then((outcome) => {
|
|
222
|
+
if (this.loadedTexture !== texture || this.texture !== requested)
|
|
223
|
+
return;
|
|
224
|
+
if (outcome === 'loaded')
|
|
217
225
|
this.rebuildGeometry();
|
|
226
|
+
else
|
|
227
|
+
this.dropFailedTexture();
|
|
218
228
|
});
|
|
219
229
|
if (this.pixelArt) {
|
|
220
230
|
texture.magFilter = THREE.NearestFilter;
|
|
221
231
|
texture.minFilter = THREE.NearestFilter;
|
|
222
232
|
}
|
|
223
|
-
texture.colorSpace = THREE.SRGBColorSpace;
|
|
224
233
|
this.loadedTexture = texture;
|
|
225
234
|
mesh.material.map = texture;
|
|
226
235
|
mesh.material.color.set(0xffffff);
|
|
227
236
|
mesh.material.needsUpdate = true;
|
|
228
237
|
}
|
|
238
|
+
/**
|
|
239
|
+
* CA-4's failure rule: an image that never arrives leaves the flat
|
|
240
|
+
* `color`, not a white map over an empty texture. Only reached while the
|
|
241
|
+
* failed clone is still the current one (see rebuildMaterial).
|
|
242
|
+
*/
|
|
243
|
+
dropFailedTexture() {
|
|
244
|
+
const mesh = this.mesh;
|
|
245
|
+
if (!mesh)
|
|
246
|
+
return;
|
|
247
|
+
this.loadedTexture?.dispose();
|
|
248
|
+
this.loadedTexture = undefined;
|
|
249
|
+
mesh.material.map = null;
|
|
250
|
+
mesh.material.color.setHex(this.color);
|
|
251
|
+
mesh.material.needsUpdate = true;
|
|
252
|
+
}
|
|
229
253
|
makeMaterial() {
|
|
230
254
|
return new THREE.MeshBasicMaterial({
|
|
231
255
|
color: this.color,
|
package/dist/game-time.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** The closed set of named Penner curves a Tween can ease through. */
|
|
2
2
|
export type EasingName = 'linear' | 'quadIn' | 'quadOut' | 'quadInOut' | 'cubicIn' | 'cubicOut' | 'cubicInOut' | 'sineInOut';
|
|
3
|
+
/** A resolved easing: normalized progress in, eased progress out. */
|
|
4
|
+
type EasingFn = (t: number) => number;
|
|
3
5
|
/** What `after`/`every`/`tween` hand back: see CA-6. */
|
|
4
6
|
export interface TimerHandle {
|
|
5
7
|
/** Idempotent and silent, even on an already-inactive handle. */
|
|
@@ -59,6 +61,8 @@ export interface TweenOptions {
|
|
|
59
61
|
/** Same as `TimerOptions.scope`: `'session'` survives a scene change, anything else means scene. */
|
|
60
62
|
scope?: 'scene' | 'session';
|
|
61
63
|
}
|
|
64
|
+
/** Engine-internal (not re-exported from the package entry): also resolves Camera Effect easings. */
|
|
65
|
+
export declare function resolveEasing(easing: EasingName | ((t: number) => number) | undefined): EasingFn | null;
|
|
62
66
|
/**
|
|
63
67
|
* Module-private key for the per-step advance pass. Not exported, so
|
|
64
68
|
* `time[ADVANCE]()` cannot be spelled outside this file — `advanceGameTime`
|
package/dist/game-time.js
CHANGED
|
@@ -15,7 +15,8 @@ const EASINGS = {
|
|
|
15
15
|
cubicInOut: (t) => (t < 0.5 ? 4 * t * t * t : 1 - ((-2 * t + 2) ** 3) / 2),
|
|
16
16
|
sineInOut: (t) => -(Math.cos(Math.PI * t) - 1) / 2,
|
|
17
17
|
};
|
|
18
|
-
|
|
18
|
+
/** Engine-internal (not re-exported from the package entry): also resolves Camera Effect easings. */
|
|
19
|
+
export function resolveEasing(easing) {
|
|
19
20
|
if (easing === undefined)
|
|
20
21
|
return EASINGS.linear;
|
|
21
22
|
if (typeof easing === 'function')
|