narraleaf-react 0.43.1 → 0.45.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/dist/game/nlcore/common/player.d.ts +1 -0
- package/dist/game/nlcore/elements/sound.d.ts +12 -3
- package/dist/game/nlcore/gameTypes.d.ts +84 -16
- package/dist/game/player/gameState.d.ts +10 -0
- package/dist/game/player/lib/AudioManager.d.ts +85 -34
- package/dist/game/player/lib/ImageCacheManager.d.ts +159 -22
- package/dist/game/player/lib/useHoldImageSrc.d.ts +19 -0
- package/dist/main.js +50 -50
- package/dist/util/data.d.ts +7 -2
- package/package.json +2 -2
|
@@ -5,4 +5,5 @@ import { useRouter } from "../../player/lib/PageRouter/router";
|
|
|
5
5
|
import { usePathname, useParams, useQueryParams } from "../../player/lib/PageRouter/routerHooks";
|
|
6
6
|
export * from "../../player/type";
|
|
7
7
|
export * from "../../player/libElements";
|
|
8
|
+
export type { ImageCacheManager, ImageCacheStats } from "../../player/lib/ImageCacheManager";
|
|
8
9
|
export { GameProviders, Player, useGame, useRouter, usePathname, useParams, useQueryParams, };
|
|
@@ -45,9 +45,18 @@ export interface ISoundUserConfig {
|
|
|
45
45
|
*/
|
|
46
46
|
rate: number;
|
|
47
47
|
/**
|
|
48
|
-
* Set to `true` to force HTML5
|
|
49
|
-
*
|
|
50
|
-
*
|
|
48
|
+
* Set to `true` to force this clip to be streamed through an HTML5 `<audio>` element instead of
|
|
49
|
+
* decoded into memory. Use it for large audio files: playback starts as soon as the first bytes
|
|
50
|
+
* arrive rather than after the whole file has been downloaded and decoded, and no decoded PCM
|
|
51
|
+
* buffer is held for as long as it plays.
|
|
52
|
+
*
|
|
53
|
+
* Leaving it `false` does not forbid streaming — it leaves the choice to the engine, which
|
|
54
|
+
* streams whole-file loops (background music, by construction) and decodes everything else. See
|
|
55
|
+
* {@link import("../gameTypes").GameConfig.audioStreaming} for that rule and how to turn it
|
|
56
|
+
* off.
|
|
57
|
+
*
|
|
58
|
+
* A streamed clip has no loop *region*: with `loop` it repeats the whole file, so
|
|
59
|
+
* {@link ISoundUserConfig.endTime} and {@link ISoundUserConfig.loopStart} are ignored for one.
|
|
51
60
|
* @default false
|
|
52
61
|
*/
|
|
53
62
|
streaming: boolean;
|
|
@@ -217,6 +217,49 @@ export type GameConfig = {
|
|
|
217
217
|
* @default 10
|
|
218
218
|
*/
|
|
219
219
|
maxPreloadActions: number;
|
|
220
|
+
/**
|
|
221
|
+
* How many bytes of fetched image data the preload cache may hold at once.
|
|
222
|
+
*
|
|
223
|
+
* Every image the cache fetches stays in memory as a blob until the cache lets it go, and a
|
|
224
|
+
* scene's registered set plus a hop of look-ahead is most of a chapter's artwork. Past this
|
|
225
|
+
* budget the least recently used images that nothing on stage is showing are released; a
|
|
226
|
+
* released image is fetched again the next time a scene wants it.
|
|
227
|
+
*
|
|
228
|
+
* The current scene's opening background and every image a mounted `<img>` is showing are
|
|
229
|
+
* never released whatever the budget says, so a budget too small for the frame on screen
|
|
230
|
+
* degrades to fetching on demand, never to a broken frame. `Infinity` removes the limit.
|
|
231
|
+
*
|
|
232
|
+
* The default is meant to hold a whole scene's registered set on a large project without
|
|
233
|
+
* releasing anything it is about to want again - a chapter's worth of artwork measured at
|
|
234
|
+
* around 200 MB of files - while capping a session that visits fifty scenes at that rather
|
|
235
|
+
* than at the size of the library. Raise it for a game whose scenes are heavier than that.
|
|
236
|
+
*
|
|
237
|
+
* @default 256 * 1024 * 1024
|
|
238
|
+
*/
|
|
239
|
+
imageCacheBudgetBytes: number;
|
|
240
|
+
/**
|
|
241
|
+
* How many bytes of decoded bitmaps the preload cache may keep decoded, at width × height × 4
|
|
242
|
+
* bytes each.
|
|
243
|
+
*
|
|
244
|
+
* A decoded bitmap is what lets an image paint the frame it is revealed on without an
|
|
245
|
+
* asynchronous decode first, and it is the expensive half of an image: a 1080p background
|
|
246
|
+
* decodes to about 8 MB whatever its file size, a 2000-pixel sprite to about 10 MB. The cache
|
|
247
|
+
* keeps the current scene's registered images decoded up to this budget, least recently used
|
|
248
|
+
* first to go past it; an image it let go of is decoded again on demand, which costs tens of
|
|
249
|
+
* milliseconds for a 1080p JPEG and is paid before the transition that reveals it starts.
|
|
250
|
+
*
|
|
251
|
+
* The current scene's opening background and every image on stage are exempt. `Infinity`
|
|
252
|
+
* removes the limit; `0` keeps nothing decoded beyond those.
|
|
253
|
+
*
|
|
254
|
+
* The default is fifteen 1080p backgrounds or a dozen large sprites - several times what a
|
|
255
|
+
* scene has on screen at once, which is a background and two or three characters - so the
|
|
256
|
+
* frame being revealed and the ones about to follow it stay warm while a chapter's worth of
|
|
257
|
+
* alternative poses does not. This is the pool that grew without limit before there was a
|
|
258
|
+
* budget, because a bitmap's size has nothing to do with how well its file compressed.
|
|
259
|
+
*
|
|
260
|
+
* @default 128 * 1024 * 1024
|
|
261
|
+
*/
|
|
262
|
+
decodedImageBudgetBytes: number;
|
|
220
263
|
/**
|
|
221
264
|
* The audio bus tree, declared by the host at boot.
|
|
222
265
|
*
|
|
@@ -257,6 +300,30 @@ export type GameConfig = {
|
|
|
257
300
|
* ```
|
|
258
301
|
*/
|
|
259
302
|
audioBuses: AudioBusDeclaration[];
|
|
303
|
+
/**
|
|
304
|
+
* Which clips are streamed through an `<audio>` element instead of being decoded into memory.
|
|
305
|
+
*
|
|
306
|
+
* Decoded audio is float32 PCM, so its size has nothing to do with the size of the file it came
|
|
307
|
+
* from: a five-minute 44.1 kHz stereo track occupies about 106 MB decoded however small the mp3
|
|
308
|
+
* was. Background music is the worst case of that — long, and resident for as long as the scene
|
|
309
|
+
* lasts — while a short effect wants to be decoded so that it can start on the frame it is
|
|
310
|
+
* asked to and overlap with itself.
|
|
311
|
+
*
|
|
312
|
+
* - `"loops"` — a clip is streamed when {@link import("./elements/sound").ISoundUserConfig.streaming} says so, **or** when
|
|
313
|
+
* it loops the whole file (`loop` with no `endTime`). A whole-file loop is background music
|
|
314
|
+
* by construction, and repeating a whole file is the one thing an `<audio>` element does
|
|
315
|
+
* exactly. A loop that marks an out point is asking for a sample-accurate loop *region*,
|
|
316
|
+
* which only a decoded buffer has, so it keeps decoding.
|
|
317
|
+
* - `"declared"` — only clips whose `streaming` is set are streamed; everything else is decoded.
|
|
318
|
+
* Choose this when a game would rather spend the memory than let its music loop through an
|
|
319
|
+
* element: `<audio>` repeats the file rather than the samples, and on some browsers and
|
|
320
|
+
* formats the loop point is audible.
|
|
321
|
+
*
|
|
322
|
+
* A `data:` or `blob:` source is already in memory and is always decoded, whichever this says.
|
|
323
|
+
*
|
|
324
|
+
* @default "loops"
|
|
325
|
+
*/
|
|
326
|
+
audioStreaming: "loops" | "declared";
|
|
260
327
|
/**
|
|
261
328
|
* Src of the cursor image, if null, the game will show the default cursor
|
|
262
329
|
* @default null
|
|
@@ -433,22 +500,6 @@ export type GameConfig = {
|
|
|
433
500
|
* @default false
|
|
434
501
|
*/
|
|
435
502
|
disableTextScaling: boolean;
|
|
436
|
-
/**
|
|
437
|
-
* How long a newly typed character takes to fade in, in milliseconds. `0` turns it off.
|
|
438
|
-
*
|
|
439
|
-
* Dialogue text is normally typed at full strength, one character appearing after another. With
|
|
440
|
-
* a duration here, each character arrives faded and comes up over that time while the typewriter
|
|
441
|
-
* carries on, so the few newest characters of a line are always part-way in and the line has a
|
|
442
|
-
* soft edge rather than a hard one.
|
|
443
|
-
*
|
|
444
|
-
* The fade never outlasts the gap between two characters by more than a little: a player who
|
|
445
|
-
* raises the typing speed gets a shorter fade, and one who raises it a long way gets none worth
|
|
446
|
-
* seeing, which is what asking for fast text means. It applies only to text actually being typed
|
|
447
|
-
* - a line revealed at once, skipped, or drawn again from a save is not faded in - and a player
|
|
448
|
-
* whose system asks for reduced motion never sees it.
|
|
449
|
-
* @default 0
|
|
450
|
-
*/
|
|
451
|
-
textRevealDuration: number;
|
|
452
503
|
/**
|
|
453
504
|
* Override the default stage
|
|
454
505
|
* @default null
|
|
@@ -495,6 +546,23 @@ export interface NotificationToken extends LiveGameEventToken {
|
|
|
495
546
|
promise: Promise<void>;
|
|
496
547
|
}
|
|
497
548
|
export type GamePreference = {
|
|
549
|
+
/**
|
|
550
|
+
* How long a newly typed character takes to fade in, in milliseconds. `0` turns it off.
|
|
551
|
+
*
|
|
552
|
+
* Dialogue text is normally typed at full strength, one character appearing after another.
|
|
553
|
+
* With a duration here each character arrives faded and comes up over that time while the
|
|
554
|
+
* typewriter carries on, so the few newest characters of a line are always part-way in and
|
|
555
|
+
* the line has a soft edge rather than a hard one.
|
|
556
|
+
*
|
|
557
|
+
* The player's, like the typing speed it is read against: someone who finds the softness
|
|
558
|
+
* distracting turns it down, and a settings screen can offer it beside `cps`. The fade never
|
|
559
|
+
* outlasts the gap between two characters by more than a little, so raising the typing speed
|
|
560
|
+
* shortens it and raising it a long way leaves none worth seeing. It applies only to text
|
|
561
|
+
* actually being typed - a line revealed at once, skipped, or drawn again from a save is not
|
|
562
|
+
* faded in - and a player whose system asks for reduced motion never sees it.
|
|
563
|
+
* @default 0
|
|
564
|
+
*/
|
|
565
|
+
textRevealDuration: number;
|
|
498
566
|
/**
|
|
499
567
|
* If true, the game will automatically forward to the next sentence when the player has finished the current sentence
|
|
500
568
|
* @default false
|
|
@@ -25,6 +25,7 @@ import { Video, VideoStateRaw } from "../nlcore/elements/video";
|
|
|
25
25
|
import { Vfx, VfxStateRaw } from "../nlcore/elements/vfx";
|
|
26
26
|
import { Timelines } from "./Tasks";
|
|
27
27
|
import { Notification, NotificationManager } from "./lib/notification";
|
|
28
|
+
import type { ImageCacheManager } from "./lib/ImageCacheManager";
|
|
28
29
|
import { ActionHistoryManager } from "../../game/nlcore/action/actionHistory";
|
|
29
30
|
import { GameHistoryManager } from "../../game/nlcore/action/gameHistory";
|
|
30
31
|
import type { Character } from "../nlcore/elements/character";
|
|
@@ -233,6 +234,15 @@ export declare class GameState {
|
|
|
233
234
|
removeElement(element: PlayerStateElement): this;
|
|
234
235
|
preloadScene(arg: Scene | Story): this;
|
|
235
236
|
getPreloadingScene(): Scene | null;
|
|
237
|
+
/**
|
|
238
|
+
* The image cache the mounted player is using, or `null` while no player is mounted.
|
|
239
|
+
*
|
|
240
|
+
* What it holds against {@link GameConfig.imageCacheBudgetBytes} and
|
|
241
|
+
* {@link GameConfig.decodedImageBudgetBytes} is readable at any time, which is what a profiler
|
|
242
|
+
* or a host checking a long session's memory wants:
|
|
243
|
+
* `game.getLiveGame().getGameState()?.getImageCache()?.getStats()`.
|
|
244
|
+
*/
|
|
245
|
+
getImageCache(): ImageCacheManager | null;
|
|
236
246
|
addElement(element: PlayerStateElement): this;
|
|
237
247
|
addScene(scene: Scene): this;
|
|
238
248
|
flush(): this;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Sound as SoundElement, SoundBusId } from "../../nlcore/elements/sound";
|
|
2
|
-
import { SoundToken } from "@narraleaf/sound";
|
|
2
|
+
import { CacheStats, SoundToken } from "@narraleaf/sound";
|
|
3
3
|
import { FadeOptions, SoundPlayOptions } from "../../nlcore/elements/type";
|
|
4
4
|
import { Awaitable } from "../../../util/data";
|
|
5
5
|
import { GameState } from "../gameState";
|
|
@@ -57,11 +57,28 @@ export declare class AudioManager {
|
|
|
57
57
|
* not have.
|
|
58
58
|
*/
|
|
59
59
|
private static readonly BusRampSettle;
|
|
60
|
+
/**
|
|
61
|
+
* How long the backend keeps a decoded clip that nothing holds any more, in milliseconds.
|
|
62
|
+
*
|
|
63
|
+
* Zero would be the tidiest answer, but a clip nothing references is exactly the shape of a
|
|
64
|
+
* short effect fired repeatedly - an interface click, a footstep - and re-fetching and
|
|
65
|
+
* re-decoding one per keystroke is worse than holding it. A few seconds is long enough to
|
|
66
|
+
* cover a burst and short enough that nothing accumulates: what is still resident after this
|
|
67
|
+
* is only what is playing or what {@link AudioManager.preload} is holding for the scene.
|
|
68
|
+
*/
|
|
69
|
+
private static readonly CacheGrace;
|
|
60
70
|
private state;
|
|
61
71
|
private channels;
|
|
62
72
|
private busTree;
|
|
63
73
|
private busSubscription;
|
|
64
74
|
private unknownBuses;
|
|
75
|
+
/**
|
|
76
|
+
* Sources this manager holds a cache reference on - the current scene's, plus anything a host
|
|
77
|
+
* preloaded by hand. One reference per distinct source, given back by
|
|
78
|
+
* {@link AudioManager.retainOnly} when the scene that wanted it is left. The value resolves to
|
|
79
|
+
* whether the reference was really taken, which a release has to wait for.
|
|
80
|
+
*/
|
|
81
|
+
private retained;
|
|
65
82
|
private globalVolume;
|
|
66
83
|
private sound;
|
|
67
84
|
private ready;
|
|
@@ -192,43 +209,40 @@ export declare class AudioManager {
|
|
|
192
209
|
*/
|
|
193
210
|
private static clipRegionOf;
|
|
194
211
|
/**
|
|
195
|
-
*
|
|
212
|
+
* Everything the backend needs to start a clip, apart from the volume the caller is fading to.
|
|
196
213
|
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
* {@link AudioManager.
|
|
201
|
-
* the backend's timer is exactly the right mechanism.
|
|
214
|
+
* The region goes over as it stands: an out point is an out point for a one-shot and the point
|
|
215
|
+
* a looping clip repeats *from* when `loop` is set, and `loopStart` moves that repeat's in
|
|
216
|
+
* point without moving where the first pass begins. A streamed clip has no region to speak of
|
|
217
|
+
* (see {@link AudioManager.loadModeOf}) and the backend ignores both keys for one.
|
|
202
218
|
*/
|
|
203
|
-
private
|
|
219
|
+
private playOptionsOf;
|
|
204
220
|
/**
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
* `loopStart` and `loopEnd` off the old node onto the new one, so a jump inside a looping track
|
|
229
|
-
* keeps the region.
|
|
221
|
+
* Whether a clip is decoded into memory or streamed through an `<audio>` element.
|
|
222
|
+
*
|
|
223
|
+
* Decoding is what a short clip wants: it can start on the frame it is asked to, several copies
|
|
224
|
+
* can overlap, and its loop region is sample-accurate. What it costs is memory, and the cost has
|
|
225
|
+
* nothing to do with the size of the file - decoded audio is float32 PCM, so a five-minute
|
|
226
|
+
* 44.1 kHz stereo track is about 106 MB however small the mp3 was. Background music is the worst
|
|
227
|
+
* case of exactly that: long, and playing for as long as the scene lasts.
|
|
228
|
+
*
|
|
229
|
+
* So a clip is streamed when it is background music by construction:
|
|
230
|
+
*
|
|
231
|
+
* - the author set `streaming` on it, which has always meant "force this one to stream";
|
|
232
|
+
* - or it loops the **whole file**. A whole-file loop is a track meant to play under a scene
|
|
233
|
+
* rather than to punctuate it, and repeating a whole file is the one thing an element does
|
|
234
|
+
* exactly. A loop with an out point is asking for a region instead, which only a decoded
|
|
235
|
+
* buffer has, so it keeps decoding.
|
|
236
|
+
*
|
|
237
|
+
* `data:` and `blob:` sources are already in memory and are always decoded; there is nothing to
|
|
238
|
+
* stream and an element would only add latency.
|
|
239
|
+
*
|
|
240
|
+
* {@link import("../../nlcore/gameTypes").GameConfig.audioStreaming} set to `"declared"` drops the
|
|
241
|
+
* second rule, for a game that would rather spend the memory than let its music loop through an
|
|
242
|
+
* element - `<audio>` repeats the file rather than the samples, so a loop point can be heard on
|
|
243
|
+
* some browsers and formats.
|
|
230
244
|
*/
|
|
231
|
-
private
|
|
245
|
+
private loadModeOf;
|
|
232
246
|
private static clampToRegion;
|
|
233
247
|
setRate(sound: SoundElement, rate: number): Awaitable<void>;
|
|
234
248
|
getPosition(sound: SoundElement): number;
|
|
@@ -242,6 +256,16 @@ export declare class AudioManager {
|
|
|
242
256
|
* Fetch and decode a sound into the audio cache without playing it, so the first `play()` of
|
|
243
257
|
* this source starts on the same frame it is asked to instead of after a fetch and a decode.
|
|
244
258
|
*
|
|
259
|
+
* **Holds the clip decoded until it is released.** A decoded clip is dropped as soon as nothing
|
|
260
|
+
* plays it, which is what keeps a session's audio from growing without bound; a preload is the
|
|
261
|
+
* one thing that says "keep this one anyway". The reference is given back by
|
|
262
|
+
* {@link AudioManager.retainOnly} - what the player preloads is the scene's set, and a scene
|
|
263
|
+
* change replaces it - or by {@link AudioManager.reset}.
|
|
264
|
+
*
|
|
265
|
+
* A clip that streams rather than decodes ({@link AudioManager.loadModeOf}) has nothing to warm:
|
|
266
|
+
* an element fetches as it plays, so there is no decoded buffer to have ready and this resolves
|
|
267
|
+
* having done nothing.
|
|
268
|
+
*
|
|
245
269
|
* Deliberately **not** something to gate a loading screen on: the audio context only becomes
|
|
246
270
|
* ready once the browser's autoplay policy is satisfied by a user gesture, so this can sit
|
|
247
271
|
* pending indefinitely on a page nobody has interacted with yet. Start it and let it land —
|
|
@@ -250,6 +274,33 @@ export declare class AudioManager {
|
|
|
250
274
|
* as it did before.
|
|
251
275
|
*/
|
|
252
276
|
preload(sound: SoundElement): Promise<void>;
|
|
277
|
+
/**
|
|
278
|
+
* Preload `sounds` and give back every cache reference this manager holds that is not among
|
|
279
|
+
* them - the audio counterpart of the image cache being filtered down to the scene that is
|
|
280
|
+
* about to paint.
|
|
281
|
+
*
|
|
282
|
+
* Called with a scene's own sounds when that scene opens. Clips shared with the previous scene
|
|
283
|
+
* keep their reference rather than being released and re-decoded, so the common case of a
|
|
284
|
+
* carried-over effect costs nothing.
|
|
285
|
+
*
|
|
286
|
+
* Releasing a reference does not stop anything: a clip that is still playing is held by its
|
|
287
|
+
* token until it ends, and only then does the buffer go.
|
|
288
|
+
*/
|
|
289
|
+
retainOnly(sounds: SoundElement[]): void;
|
|
290
|
+
/**
|
|
291
|
+
* What the audio cache is holding: decoded clips, clips in flight, and the bytes of PCM the
|
|
292
|
+
* decoded ones occupy. Zeroes before the audio context has unlocked.
|
|
293
|
+
*
|
|
294
|
+
* Here so that a game can see its own audio residency - the number that used to grow for the
|
|
295
|
+
* length of a session and now tracks what is playing plus what the current scene declared.
|
|
296
|
+
*/
|
|
297
|
+
getCacheStats(): CacheStats;
|
|
298
|
+
/**
|
|
299
|
+
* Give back the cache reference held on one source, if there is one, once it has actually been
|
|
300
|
+
* taken - a release that overtook its own load would find nothing to release and leave the
|
|
301
|
+
* reference behind for good.
|
|
302
|
+
*/
|
|
303
|
+
private releaseSource;
|
|
253
304
|
/**
|
|
254
305
|
* Start a new game: stop everything and put the mixer back on the wire.
|
|
255
306
|
*
|
|
@@ -1,13 +1,62 @@
|
|
|
1
|
-
import { Game } from "../../../game/nlcore/game";
|
|
2
|
-
import { GameState } from "../gameState";
|
|
1
|
+
import type { Game } from "../../../game/nlcore/game";
|
|
2
|
+
import type { GameState } from "../gameState";
|
|
3
3
|
export type PreloadedToken = {
|
|
4
4
|
abort: () => void;
|
|
5
5
|
onFinished: (callback: () => void) => PreloadedToken;
|
|
6
6
|
onErrored: (callback: (reason: any) => void) => PreloadedToken;
|
|
7
7
|
};
|
|
8
|
+
/**
|
|
9
|
+
* What the image cache is holding, against what it is allowed to hold. All sizes are bytes.
|
|
10
|
+
*
|
|
11
|
+
* Read through {@link GameState.getImageCache}:
|
|
12
|
+
* `game.getLiveGame().getGameState()?.getImageCache()?.getStats()`.
|
|
13
|
+
*/
|
|
14
|
+
export type ImageCacheStats = {
|
|
15
|
+
/** Sources the cache holds a url for. */
|
|
16
|
+
entries: number;
|
|
17
|
+
/** Bytes of fetched image data kept alive by the object urls the cache minted. */
|
|
18
|
+
blobBytes: number;
|
|
19
|
+
/** Sources whose decoded bitmap the cache is holding on to. */
|
|
20
|
+
decodedEntries: number;
|
|
21
|
+
/** Estimated size of those bitmaps, at width × height × 4 bytes each. */
|
|
22
|
+
decodedBytes: number;
|
|
23
|
+
/** Entries that cannot be evicted right now: shown by a mounted `<img>`, or pinned by the scene. */
|
|
24
|
+
pinned: number;
|
|
25
|
+
/** The budgets in force: {@link GameConfig.imageCacheBudgetBytes} and {@link GameConfig.decodedImageBudgetBytes}. */
|
|
26
|
+
budget: {
|
|
27
|
+
blobBytes: number;
|
|
28
|
+
decodedBytes: number;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* The player's image cache: fetched bytes as object urls, and decoded bitmaps for the images about
|
|
33
|
+
* to be revealed, both under a memory budget.
|
|
34
|
+
*
|
|
35
|
+
* The cache used to be unbounded, and what it held was decided by the preload pass after the one
|
|
36
|
+
* that filled it: a scene's registered images - every pose of every character it shows, every
|
|
37
|
+
* background it cuts to - stayed fetched and decoded until the *next* scene's look-ahead pool had
|
|
38
|
+
* finished, and stayed for ever if that pass was superseded before it got there. A long session on
|
|
39
|
+
* a large library grew without limit and ended in a renderer out of memory. Now:
|
|
40
|
+
*
|
|
41
|
+
* - Both pools have a budget, read live from the game config. Past it, the least recently used
|
|
42
|
+
* entries that nothing pins go first: a bitmap is dropped and decoded again on demand, a fetched
|
|
43
|
+
* image is released and fetched again when a scene wants it. Recency is the {@link entries} map's
|
|
44
|
+
* order, refreshed by every {@link get}.
|
|
45
|
+
* - Two things pin an entry against both budgets. A scene pins its opening background through
|
|
46
|
+
* {@link pin}; and every mounted `<img>` holds the url it is showing through {@link hold}, so the
|
|
47
|
+
* frame on screen - both halves of a transition in flight, a caller parked behind a scene call -
|
|
48
|
+
* is never taken out from under the DOM.
|
|
49
|
+
* - What a scene keeps is decided when its pass starts, through {@link retain}: everything outside
|
|
50
|
+
* the plan is stale, dropped at once if nothing shows it and the moment its last `<img>` unmounts
|
|
51
|
+
* otherwise. That is what releases a left scene at scene exit rather than a pass later.
|
|
52
|
+
*/
|
|
8
53
|
export declare class ImageCacheManager {
|
|
9
54
|
private readonly game;
|
|
10
|
-
|
|
55
|
+
/** Fetch `src` and hand back a url for its bytes, and how many there are. Mocked by tests. */
|
|
56
|
+
static getImage(src: string, abortSignal?: AbortSignal, options?: RequestInit): Promise<{
|
|
57
|
+
url: string;
|
|
58
|
+
bytes: number;
|
|
59
|
+
}>;
|
|
11
60
|
/**
|
|
12
61
|
* Decode an image source off-screen so the browser's decoded-image cache is warm before
|
|
13
62
|
* the source is ever attached to a visible `<img>`. Without this, "preloaded" only means
|
|
@@ -20,29 +69,49 @@ export declare class ImageCacheManager {
|
|
|
20
69
|
* `Image` or no `decode()`.
|
|
21
70
|
*/
|
|
22
71
|
private static decodeImage;
|
|
23
|
-
private src;
|
|
24
|
-
private preloadTasks;
|
|
25
72
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* bitmap be evicted and the reveal decodes all over again. Retention is opt-in per preload
|
|
29
|
-
* (`retainDecoded`) because a full-resolution bitmap costs width × height × 4 bytes — worth
|
|
30
|
-
* it for the scene that is about to paint, far too expensive for a whole reachable graph.
|
|
73
|
+
* What a retained bitmap costs. The browser keeps a decoded image as 32-bit pixels at its
|
|
74
|
+
* natural size whatever the file's format or size was, so this is the number that matters.
|
|
31
75
|
*/
|
|
32
|
-
private
|
|
76
|
+
private static estimateDecodedBytes;
|
|
77
|
+
/** A budget as configured; anything that is not a number reads as no limit. */
|
|
78
|
+
private static readBudget;
|
|
79
|
+
/**
|
|
80
|
+
* Every cached source, least recently used first. A touch deletes and re-inserts its entry,
|
|
81
|
+
* so eviction walks the map from the front.
|
|
82
|
+
*/
|
|
83
|
+
private entries;
|
|
84
|
+
/** The same entries by the url an `<img>` shows, which is the only name an `<img>` knows. */
|
|
85
|
+
private byUrl;
|
|
86
|
+
private preloadTasks;
|
|
87
|
+
/** Urls some mounted `<img>` is showing, with how many are showing each. */
|
|
88
|
+
private holds;
|
|
89
|
+
/** Sources the scene pinned - its opening background - replaced as a whole by {@link pin}. */
|
|
90
|
+
private pinned;
|
|
91
|
+
/** Every source that has been through the cache, kept for {@link wasCached}. */
|
|
92
|
+
private seen;
|
|
93
|
+
private blobBytes;
|
|
94
|
+
private decodedBytes;
|
|
33
95
|
constructor(game: Game);
|
|
96
|
+
has(name: string): boolean;
|
|
34
97
|
/**
|
|
35
|
-
*
|
|
98
|
+
* Whether this source has ever been through the cache, whether or not it still is.
|
|
36
99
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
100
|
+
* For diagnostics that want to tell "the preloader never heard of this image" - which means an
|
|
101
|
+
* image action nothing could predict, and is worth telling the author about - from "the cache
|
|
102
|
+
* had it and a budget released it again", which is the cache working as designed and is not.
|
|
103
|
+
*/
|
|
104
|
+
wasCached(name: string): boolean;
|
|
105
|
+
/**
|
|
106
|
+
* Cache `src` under `name` without fetching it. The bytes behind a url given this way are not
|
|
107
|
+
* the cache's to count, so it weighs nothing against the fetched budget.
|
|
40
108
|
*/
|
|
41
|
-
private release;
|
|
42
|
-
private releaseAll;
|
|
43
|
-
has(name: string): boolean;
|
|
44
109
|
add(name: string, src: string): this;
|
|
45
110
|
remove(name: string): this;
|
|
111
|
+
/**
|
|
112
|
+
* The url to show for `name`, or `undefined` when it is not cached. This is the cache's notion
|
|
113
|
+
* of a use: the entry becomes the most recently used one.
|
|
114
|
+
*/
|
|
46
115
|
get(name: string): string | undefined;
|
|
47
116
|
/**
|
|
48
117
|
* Whether this source has been decoded and its decoded bitmap is still held, i.e. attaching
|
|
@@ -56,9 +125,16 @@ export declare class ImageCacheManager {
|
|
|
56
125
|
* Fetch `url`, cache it as an object URL and decode it, resolving the returned token's
|
|
57
126
|
* `onFinished` only once the decode has settled.
|
|
58
127
|
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* look-ahead
|
|
128
|
+
* Asking again is safe and converges. A url that is cached and, if asked, decoded settles at
|
|
129
|
+
* once. One that is cached but whose bitmap the budget let go of - or that was fetched as
|
|
130
|
+
* look-ahead, which never retains - is decoded again and held when `retainDecoded` asks for it,
|
|
131
|
+
* so a first frame the budget evicted still gates on a decode rather than on nothing. One that
|
|
132
|
+
* another pass is still fetching follows that fetch instead of getting a token that never fires.
|
|
133
|
+
*
|
|
134
|
+
* @param options.retainDecoded keep the decoded bitmap alive, within
|
|
135
|
+
* {@link GameConfig.decodedImageBudgetBytes}, until this source leaves the cache. Use it for the
|
|
136
|
+
* assets that are about to be revealed; leave it off for speculative look-ahead preloading,
|
|
137
|
+
* whose bitmaps would otherwise pile up in memory.
|
|
62
138
|
*/
|
|
63
139
|
preload(gameState: GameState, url: string, options?: {
|
|
64
140
|
retainDecoded?: boolean;
|
|
@@ -66,5 +142,66 @@ export declare class ImageCacheManager {
|
|
|
66
142
|
abortAll(): void;
|
|
67
143
|
abort(src: string): void;
|
|
68
144
|
preloadedSrc(): string[];
|
|
69
|
-
|
|
145
|
+
/**
|
|
146
|
+
* Keep `names` and let everything else go.
|
|
147
|
+
*
|
|
148
|
+
* Called when a scene's preload pass starts, with everything the pass is about to want. An entry
|
|
149
|
+
* outside the set is dropped at once unless something pins it; a pinned one is marked stale and
|
|
150
|
+
* dropped the moment it is not - which for the scene that was just left is the moment its last
|
|
151
|
+
* `<img>` unmounts. An entry that is back in the set is not stale any more.
|
|
152
|
+
*/
|
|
153
|
+
retain(names: Iterable<string>): this;
|
|
154
|
+
/**
|
|
155
|
+
* Exempt `names` from both budgets, replacing whatever was pinned before.
|
|
156
|
+
*
|
|
157
|
+
* For the current scene's opening background: fetched and decoded before the scene is allowed
|
|
158
|
+
* to paint, and the one image a budget must never take back. Whatever a mounted `<img>` shows is
|
|
159
|
+
* pinned separately, through {@link hold}, so this only needs to name what is about to be shown.
|
|
160
|
+
*/
|
|
161
|
+
pin(names: Iterable<string>): this;
|
|
162
|
+
/**
|
|
163
|
+
* Report that a mounted `<img>` is showing `url`; the entry behind it cannot be evicted until
|
|
164
|
+
* every such `<img>` has called {@link release}. By url rather than by source name, because the
|
|
165
|
+
* url is all an `<img>` knows - and a url the cache never minted is remembered and ignored.
|
|
166
|
+
*/
|
|
167
|
+
hold(url: string): void;
|
|
168
|
+
/** Undo one {@link hold}. The last release drops a stale entry, and lets the budgets run again. */
|
|
169
|
+
release(url: string): void;
|
|
170
|
+
getStats(): ImageCacheStats;
|
|
171
|
+
private budget;
|
|
172
|
+
private isPinned;
|
|
173
|
+
private touch;
|
|
174
|
+
private insert;
|
|
175
|
+
/**
|
|
176
|
+
* Drop an entry entirely, handing back its object url.
|
|
177
|
+
*
|
|
178
|
+
* Every path that drops an entry goes through this. An object URL pins its blob for the
|
|
179
|
+
* lifetime of the document, so a cache that forgets an entry without revoking leaks the whole
|
|
180
|
+
* image - which on a scene change is most of a scene's artwork.
|
|
181
|
+
*/
|
|
182
|
+
private drop;
|
|
183
|
+
/** Let the bitmap go and keep the bytes; the browser decodes them again when they are next shown. */
|
|
184
|
+
private dropDecoded;
|
|
185
|
+
private retainDecoded;
|
|
186
|
+
/**
|
|
187
|
+
* Bring both pools back under budget, least recently used first, skipping what is pinned.
|
|
188
|
+
*
|
|
189
|
+
* Fetched bytes first: dropping an entry takes its bitmap with it, and may settle the decoded
|
|
190
|
+
* pool on its own. An entry that is over a budget all by itself goes too, unless it is pinned -
|
|
191
|
+
* the budget is a limit, not a preference, and what is on stage is protected by the pins.
|
|
192
|
+
*/
|
|
193
|
+
private enforce;
|
|
194
|
+
private fetchAndDecode;
|
|
195
|
+
/** A cached entry's bitmap, wanted again after the budget let it go or a look-ahead skipped it. */
|
|
196
|
+
private decodeAgain;
|
|
197
|
+
private runTask;
|
|
198
|
+
/**
|
|
199
|
+
* A token for a url another caller is already fetching. It finishes when that fetch has landed
|
|
200
|
+
* *and* this caller's own request is met: the running task may be a look-ahead that retains no
|
|
201
|
+
* bitmap, so asking again once it is done settles the difference - unless it failed, in which
|
|
202
|
+
* case asking again would only fetch a broken url once per follower.
|
|
203
|
+
*/
|
|
204
|
+
private followTask;
|
|
205
|
+
/** A token for a request that is already met; it finishes on the next microtask, like the others. */
|
|
206
|
+
private static settledToken;
|
|
70
207
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { RefObject } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Tell the image cache which url this `<img>` is showing, for as long as it shows it.
|
|
4
|
+
*
|
|
5
|
+
* The cache evicts by budget and drops a left scene's images as soon as nothing needs them, and
|
|
6
|
+
* "nothing needs them" has exactly one witness: whether some mounted `<img>` still has the url as
|
|
7
|
+
* its `src`. So the leaf reports it. The attribute is watched rather than a prop, because a
|
|
8
|
+
* non-layered image's `src` is written imperatively, frame by frame, by its transition and never
|
|
9
|
+
* passes through React - and because both halves of a transition in flight are `<img>`s, which is
|
|
10
|
+
* what keeps the outgoing picture from being taken back mid-fade.
|
|
11
|
+
*
|
|
12
|
+
* Layout effects rather than passive ones: the first hold has to land in the same task as the
|
|
13
|
+
* commit that wrote the `src`, before any fetch that finishes in between gets to run the budget.
|
|
14
|
+
* The attaching one runs after every commit rather than only the first, because the element may
|
|
15
|
+
* not exist yet on the pass this hook is first called on - a dialogue avatar renders nothing until
|
|
16
|
+
* there is a speaker with one - and because React may replace the node underneath the ref. It does
|
|
17
|
+
* nothing at all once it is already watching the right element.
|
|
18
|
+
*/
|
|
19
|
+
export declare function useHoldImageSrc(ref: RefObject<HTMLImageElement | null>): void;
|