narraleaf-react 0.45.1 → 0.46.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.
@@ -6,4 +6,5 @@ export * from "./types";
6
6
  export * from "./position";
7
7
  export * from "./transition";
8
8
  export * from "./interface";
9
+ export type { PreloadAcquisition, PreloadBand, PreloadEntry, PreloadMoment, PreloadPlan, PreloadResource, PreloadResourceType, PreloadStrategy, } from "../preload/types";
9
10
  export { i, c, b };
@@ -6,4 +6,5 @@ import { usePathname, useParams, useQueryParams } from "../../player/lib/PageRou
6
6
  export * from "../../player/type";
7
7
  export * from "../../player/libElements";
8
8
  export type { ImageCacheManager, ImageCacheStats } from "../../player/lib/ImageCacheManager";
9
+ export { createDefaultPreloadStrategy } from "../../player/elements/preload/defaultStrategy";
9
10
  export { GameProviders, Player, useGame, useRouter, usePathname, useParams, useQueryParams, };
@@ -11,6 +11,7 @@ import type { GameElementHistory } from "./action/gameHistory";
11
11
  import { MenuComponent, NotificationComponent, NvlDialogComponent, SayComponent } from "./common/player";
12
12
  import { LiveGameEventToken } from "./types";
13
13
  import type { AudioBusDeclaration } from "./game/audioBus";
14
+ import type { PreloadStrategy } from "./preload/types";
14
15
  /**
15
16
  * Current save format version.
16
17
  *
@@ -168,10 +169,30 @@ export type GameConfig = {
168
169
  * @default 50
169
170
  */
170
171
  ratioUpdateInterval: number;
172
+ /**
173
+ * Who decides what the player warms, when, and where the bytes come from.
174
+ *
175
+ * Unset, the player uses its built-in strategy, which is the behaviour it has always had: walk
176
+ * the action tree of the scene about to paint and of the scenes reachable from it, split the
177
+ * result into a first frame, the scene's registered set and a look-ahead, and fetch each one
178
+ * into an object url with an off-screen decode. Every `preload*` field below steers that
179
+ * strategy and nothing else.
180
+ *
181
+ * Set it and the player stops guessing. It asks this object what should be warm at each moment
182
+ * in the story and does exactly that, and if the object also supplies
183
+ * {@link PreloadStrategy.acquire} it stops fetching too - which is what lets a host whose assets
184
+ * are already on local disk hand back the url it was given and keep no second copy in memory.
185
+ * The cache, its budgets and the pins are still the player's; the plan is not.
186
+ *
187
+ * See {@link PreloadStrategy}.
188
+ */
189
+ preload?: PreloadStrategy;
171
190
  /**
172
191
  * The game will preload the image with this delay between each preload task
173
192
  *
174
- * A single preload task may contain {@link GameConfig.preloadConcurrency} images
193
+ * A single preload task may contain {@link GameConfig.preloadConcurrency} images.
194
+ * Read by the built-in strategy's speculative band only; a game with its own
195
+ * {@link GameConfig.preload} paces that band with the same field.
175
196
  * @default 100
176
197
  */
177
198
  preloadDelay: number;
@@ -0,0 +1,174 @@
1
+ import type { Scene } from "../elements/scene";
2
+ import type { Sound } from "../elements/sound";
3
+ import type { Story } from "../elements/story";
4
+ /**
5
+ * The preload seam: what the player warms, when it warms it, and where the bytes come from.
6
+ *
7
+ * ## Why this is a seam and not a policy
8
+ *
9
+ * The player used to answer three questions on its own. *What will be needed* it guessed, by
10
+ * walking the action tree of the scene about to paint and of every scene reachable from it. *When*
11
+ * followed from that walk: one pass per scene, split into a first frame, the scene's whole
12
+ * registered set, and a look-ahead. *How* was fixed: fetch the bytes, mint an object url, decode
13
+ * the image off-screen, hold the bitmap under a budget.
14
+ *
15
+ * All three answers are wrong for a host that knows more than the walk can see. A tool that
16
+ * compiled the story knows exactly which row shows which asset, in which order, and how big each
17
+ * one is; the walk only knows what a scene mentions anywhere in it, which on a real project is
18
+ * most of the library - so the pass fetched and decoded a chapter's artwork in order to paint one
19
+ * background. A host serving assets off local disk has no use for the object url either: the file
20
+ * is already there, and copying it into a blob costs that memory a second time.
21
+ *
22
+ * So the player now asks instead of deciding. A {@link PreloadStrategy} answers *what* and *when*
23
+ * as a {@link PreloadPlan}, and may answer *how* as well through
24
+ * {@link PreloadStrategy.acquire}. Everything the player still owns - the cache, its budgets, what
25
+ * a mounted element pins, and the url an element is finally pointed at - is unchanged, which is
26
+ * what lets a strategy replace the plan without replacing the player.
27
+ *
28
+ * A game that supplies no strategy gets the built-in one, which reproduces the walk and the tiers
29
+ * exactly as they were, driven by the same `preload*` fields of `GameConfig`.
30
+ */
31
+ /** What kind of thing a plan entry names. Audio is not one of these - see {@link PreloadPlan.audio}. */
32
+ export type PreloadResourceType = "image" | "video";
33
+ /** One thing a plan can ask for, named by the url the stage will show. */
34
+ export type PreloadResource = {
35
+ type: PreloadResourceType;
36
+ /**
37
+ * The url the stage points an element at, which is also the key the cache stores it under.
38
+ *
39
+ * A strategy that rewrites urls - a host serving through its own protocol, say - must name the
40
+ * resource by the url the *stage* will use and do the rewriting inside
41
+ * {@link PreloadStrategy.acquire}. The two are the same string for every ordinary game.
42
+ */
43
+ src: string;
44
+ };
45
+ /**
46
+ * How urgently a resource is wanted. One axis, three points, and only the first of them blocks.
47
+ *
48
+ * - `gate` - the frame is not allowed to paint until this has landed. That is the loading screen's
49
+ * whole meaning, so a plan that puts a chapter in this band is a plan that opens late.
50
+ * - `soon` - start now, at full speed, but nothing waits on it: what the player is a click away
51
+ * from needing.
52
+ * - `idle` - speculative. Paced by `GameConfig.preloadDelay`, run after the other two, and
53
+ * abandoned without ceremony when the moment is superseded.
54
+ */
55
+ export type PreloadBand = "gate" | "soon" | "idle";
56
+ export type PreloadEntry = PreloadResource & {
57
+ band: PreloadBand;
58
+ /**
59
+ * Whether to decode the image off-screen before anything shows it, and hold the bitmap.
60
+ *
61
+ * A decode is what lets an image paint on the frame it is revealed on rather than a frame or
62
+ * two later, and it is the expensive half of warming one: measured over a real library,
63
+ * fetching every image took 473 ms and fetching *and* decoding them took 2,140 ms, with each
64
+ * retained bitmap costing width x height x 4 bytes for as long as it is held. So it is worth
65
+ * paying for what is about to be revealed and wasteful for what merely might be.
66
+ *
67
+ * Defaults to true on the `gate` and `soon` bands and false on `idle`.
68
+ */
69
+ decode?: boolean;
70
+ };
71
+ /**
72
+ * What should be warm at one moment in the story, and what may be forgotten.
73
+ *
74
+ * A plan is complete: it replaces the previous one rather than adding to it. That is what makes
75
+ * {@link PreloadPlan.keep} meaningful - a scene the story has left keeps nothing, and its artwork
76
+ * is released as soon as no element is still showing it.
77
+ */
78
+ export type PreloadPlan = {
79
+ /** Every image and video this moment wants, in the order each band should warm them. */
80
+ readonly entries: readonly PreloadEntry[];
81
+ /**
82
+ * Sounds the audio cache should hold for this moment, and only these.
83
+ *
84
+ * Separate from {@link PreloadPlan.entries}, and named by element rather than by url, because
85
+ * audio is warmed by a different cache with a different budget, and whether a clip is decoded
86
+ * into memory or streamed as it plays is a property of the sound rather than of its url. It is
87
+ * also never gated on: the audio context stays locked until the page has been interacted with,
88
+ * so a loading screen that waited for a clip could wait for ever.
89
+ */
90
+ readonly audio?: readonly Sound[];
91
+ /**
92
+ * Every url the image cache may keep, which is normally the plan's own entries.
93
+ *
94
+ * Anything outside this set is released at once if nothing is showing it, and the moment its
95
+ * last element unmounts otherwise. Omit it to leave what the cache holds alone - which is what
96
+ * a plan that only adds to a scene's warm set wants.
97
+ */
98
+ readonly keep?: readonly string[];
99
+ /**
100
+ * Urls no budget may release, whatever else happens - normally the opening frame.
101
+ *
102
+ * Whatever a mounted element is showing is protected separately and does not need naming here.
103
+ */
104
+ readonly pin?: readonly string[];
105
+ };
106
+ /** Why the player is asking. A strategy may answer only the moments it cares about. */
107
+ export type PreloadMoment = {
108
+ /** A scene is about to paint, or has just been entered. */
109
+ kind: "scene";
110
+ scene: Scene;
111
+ story: Story | null;
112
+ } | {
113
+ /**
114
+ * The story advanced. Sent for every action, so a strategy that plans row by row answers here
115
+ * and one that plans per scene returns null.
116
+ */
117
+ kind: "advance";
118
+ actionId: string | null;
119
+ scene: Scene | null;
120
+ story: Story | null;
121
+ };
122
+ /**
123
+ * Where the bytes for one resource came from, and what keeping them costs.
124
+ *
125
+ * Returned by {@link PreloadStrategy.acquire}. A host that serves assets from local disk should
126
+ * hand back the url it was given with `bytes: 0` and no `release`: the browser then fetches and
127
+ * caches the file once, the way it would for any other url on the page, and the player holds no
128
+ * second copy of it in the renderer's heap.
129
+ */
130
+ export type PreloadAcquisition = {
131
+ /** The url an element should be pointed at. May be the resource's own url. */
132
+ url: string;
133
+ /**
134
+ * What holding this costs the player's fetched-bytes budget. Zero when the host owns the
135
+ * memory, which is the honest answer for a url the player copied nothing for.
136
+ */
137
+ bytes?: number;
138
+ /** Called once when the player lets the entry go, for a url the host has to clean up. */
139
+ release?: () => void;
140
+ };
141
+ /**
142
+ * The host's answer to what the player should warm, and optionally to how.
143
+ *
144
+ * Supplied as `GameConfig.preload`. Every method is asked on the player's own schedule; none may
145
+ * assume it is called in order, and all may be called again for the same moment after a reload.
146
+ */
147
+ export interface PreloadStrategy {
148
+ /**
149
+ * What should be warm at this moment, or null to leave the previous plan in force.
150
+ *
151
+ * May be asynchronous: a host that has to ask another process what a scene uses answers when it
152
+ * knows. The player will not paint a gated frame until the plan has arrived and its `gate` band
153
+ * has landed, so a strategy that takes its time is a strategy that opens late - the same trade
154
+ * the built-in one makes, only visible.
155
+ */
156
+ plan(moment: PreloadMoment): PreloadPlan | null | Promise<PreloadPlan | null>;
157
+ /**
158
+ * Obtain the url for one resource, replacing the player's own fetch.
159
+ *
160
+ * Omit it and the player fetches the resource itself and mints an object url, which is what it
161
+ * has always done. Return null to say "nothing to warm, show it directly": the player then
162
+ * points the element at the resource's own url and caches nothing for it.
163
+ */
164
+ acquire?(resource: PreloadResource, signal: AbortSignal): Promise<PreloadAcquisition | null>;
165
+ /**
166
+ * Told when the stage shows something no plan named.
167
+ *
168
+ * This is the case the player used to report by asking the author, in a console warning, to
169
+ * register the image by hand. A host that plans from a compiled story can say something far
170
+ * more useful - which row shows it - so the player hands the fact over rather than guessing at
171
+ * the remedy. Called at most once per url.
172
+ */
173
+ onMissing?(resource: PreloadResource): void;
174
+ }
@@ -0,0 +1,16 @@
1
+ import type { Game } from "../../../nlcore/game";
2
+ import type { PreloadStrategy } from "../../../nlcore/preload/types";
3
+ /**
4
+ * The strategy a game gets when it supplies none: the walk and the tiers exactly as they were.
5
+ *
6
+ * It exists for two reasons beyond compatibility. It is the only proof that the seam is wide
7
+ * enough - if the behaviour the player shipped for years cannot be expressed as a
8
+ * {@link PreloadPlan}, the seam is the wrong shape. And it is what a host can fall back to for the
9
+ * parts of a story it does not know about, since a strategy is free to call this one and merge.
10
+ *
11
+ * Everything it does is read off `GameConfig`, so the fields that used to steer the player directly
12
+ * now steer this: `preloadAllImages` chooses between the two passes below, `preloadGate` decides
13
+ * whether the scene's whole registered set blocks the first frame or only its opening background,
14
+ * and `maxPreloadActions` sizes the prediction window.
15
+ */
16
+ export declare function createDefaultPreloadStrategy(game: Game): PreloadStrategy;
@@ -1,5 +1,6 @@
1
1
  import type { Game } from "../../../game/nlcore/game";
2
2
  import type { GameState } from "../gameState";
3
+ import type { PreloadStrategy } from "../../nlcore/preload/types";
3
4
  export type PreloadedToken = {
4
5
  abort: () => void;
5
6
  onFinished: (callback: () => void) => PreloadedToken;
@@ -92,7 +93,37 @@ export declare class ImageCacheManager {
92
93
  private seen;
93
94
  private blobBytes;
94
95
  private decodedBytes;
96
+ /**
97
+ * How the cache gets bytes for a source, when the host would rather it did not fetch them.
98
+ *
99
+ * Installed by the preloader from `GameConfig.preload`. Unset, the cache fetches the url and
100
+ * mints an object url for it, which is what it has always done - and which costs the renderer a
101
+ * second copy of every image on a host whose assets are already on local disk.
102
+ */
103
+ private acquisition;
104
+ /** Where a source nothing warmed is reported, when the host wants to hear about it. */
105
+ private missingReporter;
106
+ /** Sources already reported missing, so one unpredicted image is one report and not one a frame. */
107
+ private reportedMissing;
95
108
  constructor(game: Game);
109
+ /**
110
+ * Install the host's way of obtaining bytes, or clear it.
111
+ *
112
+ * Set once, from the preloader, before anything is warmed. Entries already in the cache keep
113
+ * whatever url they were built with; the cache never re-acquires something it holds.
114
+ */
115
+ useAcquisition(acquire: PreloadStrategy["acquire"] | null): this;
116
+ /** Install the host's ear for sources nothing warmed, or clear it. */
117
+ useMissingReporter(onMissing: PreloadStrategy["onMissing"] | null): this;
118
+ /**
119
+ * Say that the stage is showing `src` and no plan named it. Answers whether the host took it.
120
+ *
121
+ * The player's own answer to this was a console warning telling the author to register the
122
+ * image by hand, which is only useful to someone who writes the story in TypeScript. A host that
123
+ * planned from a compiled story can name the row instead, so it gets first refusal and the
124
+ * warning stays for the games that have no host to ask.
125
+ */
126
+ reportMissing(src: string): boolean;
96
127
  has(name: string): boolean;
97
128
  /**
98
129
  * Whether this source has ever been through the cache, whether or not it still is.
@@ -135,9 +166,14 @@ export declare class ImageCacheManager {
135
166
  * {@link GameConfig.decodedImageBudgetBytes}, until this source leaves the cache. Use it for the
136
167
  * assets that are about to be revealed; leave it off for speculative look-ahead preloading,
137
168
  * whose bitmaps would otherwise pile up in memory.
169
+ * @param options.decode run the off-screen decode at all. Defaults to true. Off, the bytes are
170
+ * obtained and nothing else: measured over a real library, that is the difference between 473
171
+ * and 2,140 milliseconds, and it is the right trade for anything the plan does not expect to be
172
+ * revealed soon. `retainDecoded` implies a decode and overrides this.
138
173
  */
139
174
  preload(gameState: GameState, url: string, options?: {
140
175
  retainDecoded?: boolean;
176
+ decode?: boolean;
141
177
  }): PreloadedToken;
142
178
  abortAll(): void;
143
179
  abort(src: string): void;
@@ -191,7 +227,23 @@ export declare class ImageCacheManager {
191
227
  * the budget is a limit, not a preference, and what is on stage is protected by the pins.
192
228
  */
193
229
  private enforce;
194
- private fetchAndDecode;
230
+ /**
231
+ * Get the bytes for one source and, unless told not to, decode them off-screen.
232
+ *
233
+ * The acquisition step is the host's when one is installed: it may hand back the url unchanged
234
+ * and own the memory itself, which is what a host serving local files should do. Otherwise the
235
+ * cache fetches and mints an object url, and is the thing that has to revoke it.
236
+ */
237
+ private acquireAndDecode;
238
+ /** The bytes for one source, from the host when it has an opinion and by fetching otherwise. */
239
+ private acquire;
240
+ /**
241
+ * Give a url back to whoever owns it: the host that supplied it, or the browser that minted it.
242
+ *
243
+ * One place, because the two are indistinguishable to every caller and getting it wrong leaks a
244
+ * whole image - an object url pins its blob for the lifetime of the document.
245
+ */
246
+ private handBack;
195
247
  /** A cached entry's bitmap, wanted again after the budget let it go or a look-ahead skipped it. */
196
248
  private decodeAgain;
197
249
  private runTask;