sci2-ts 0.15.0 → 0.16.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/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@ All notable changes to this project. Versions follow [Semantic Versioning](https
4
4
  and the entries are generated from commit messages
5
5
  ([Conventional Commits](https://www.conventionalcommits.org/)) when a release is made.
6
6
 
7
+ ## [0.16.0](https://github.com/rlueder/sci-ts/compare/v0.15.1...v0.16.0) (2026-10-02)
8
+
9
+ ### Added
10
+
11
+ * **lib:** a lens magnifies what's under it, and portraits don't cover faces ([#19](https://github.com/rlueder/sci-ts/issues/19)) ([cc901d2](https://github.com/rlueder/sci-ts/commit/cc901d23716fad1b6466950fb77d7809f00b7f00))
12
+
13
+ ## [0.15.1](https://github.com/rlueder/sci-ts/compare/v0.15.0...v0.15.1) (2026-10-02)
14
+
15
+ ### Fixed
16
+
17
+ * **viewer:** fetch game files with the build's version ([#18](https://github.com/rlueder/sci-ts/issues/18)) ([370e490](https://github.com/rlueder/sci-ts/commit/370e490d9c618d78223fbba1df4850a8c6ebb0a2))
18
+
7
19
  ## [0.15.0](https://github.com/rlueder/sci-ts/compare/v0.14.0...v0.15.0) (2026-10-02)
8
20
 
9
21
  ### Added
@@ -4,6 +4,11 @@ export declare const BASE: string;
4
4
  /**
5
5
  * FileSource over game/: the dev server's mount (see vite.config.ts), or the game copied
6
6
  * next to a built site, with its file names in game/files.json (`pnpm game site`).
7
+ *
8
+ * A built site's files.json also has the build's `version`. It's always fetched fresh, and
9
+ * every game file is fetched with the version on its URL, so after a new build a browser
10
+ * can't mix its cached files with the new ones (an old RESOURCE.000 under a new RESOURCE.MAP
11
+ * doesn't start).
7
12
  */
8
13
  export declare const httpFiles: FileSource;
9
14
  /**
@@ -5,14 +5,19 @@ export const BASE = import.meta.env.BASE_URL;
5
5
  /**
6
6
  * FileSource over game/: the dev server's mount (see vite.config.ts), or the game copied
7
7
  * next to a built site, with its file names in game/files.json (`pnpm game site`).
8
+ *
9
+ * A built site's files.json also has the build's `version`. It's always fetched fresh, and
10
+ * every game file is fetched with the version on its URL, so after a new build a browser
11
+ * can't mix its cached files with the new ones (an old RESOURCE.000 under a new RESOURCE.MAP
12
+ * doesn't start).
8
13
  */
9
14
  export const httpFiles = {
10
15
  async read(path) {
11
- const res = await fetch(`${BASE}game/${path}`);
16
+ const res = await fetch(await gameUrl(path));
12
17
  return res.ok ? new Uint8Array(await res.arrayBuffer()) : undefined;
13
18
  },
14
19
  async readRange(path, offset, length) {
15
- const res = await fetch(`${BASE}game/${path}`, { headers: { Range: `bytes=${offset}-${offset + length - 1}` } });
20
+ const res = await fetch(await gameUrl(path), { headers: { Range: `bytes=${offset}-${offset + length - 1}` } });
16
21
  const bytes = new Uint8Array(await res.arrayBuffer());
17
22
  // A server that ignores Range (Python's http.server) sends the whole file.
18
23
  return res.status === 206 ? bytes : bytes.subarray(offset, offset + length);
@@ -20,11 +25,20 @@ export const httpFiles = {
20
25
  async list(dir) {
21
26
  if (import.meta.env.DEV)
22
27
  return (await fetch(`/__list?dir=${encodeURIComponent(dir)}`)).json();
23
- staticListing ??= fetch(`${BASE}game/files.json`).then((r) => (r.ok ? r.json() : {}));
24
- return (await staticListing)[dir] ?? [];
28
+ const files = (await staticListing())[dir];
29
+ return Array.isArray(files) ? files : [];
25
30
  },
26
31
  };
27
- let staticListing;
32
+ let listing;
33
+ const staticListing = () => (listing ??= fetch(`${BASE}game/files.json`, { cache: "no-store" }).then((r) => (r.ok ? r.json() : Promise.resolve({}))));
34
+ /** A game file's URL: on a built site, with the build's version (see httpFiles). */
35
+ async function gameUrl(path) {
36
+ const url = `${BASE}game/${path}`;
37
+ if (import.meta.env.DEV)
38
+ return url;
39
+ const version = (await staticListing()).version;
40
+ return typeof version === "string" ? `${url}?v=${encodeURIComponent(version)}` : url;
41
+ }
28
42
  // --- Mods -------------------------------------------------------------------------------
29
43
  const MODS_KEY = `${APP_ID}.mods`;
30
44
  /**
@@ -7,25 +7,40 @@ export const BASE = import.meta.env.BASE_URL;
7
7
  /**
8
8
  * FileSource over game/: the dev server's mount (see vite.config.ts), or the game copied
9
9
  * next to a built site, with its file names in game/files.json (`pnpm game site`).
10
+ *
11
+ * A built site's files.json also has the build's `version`. It's always fetched fresh, and
12
+ * every game file is fetched with the version on its URL, so after a new build a browser
13
+ * can't mix its cached files with the new ones (an old RESOURCE.000 under a new RESOURCE.MAP
14
+ * doesn't start).
10
15
  */
11
16
  export const httpFiles: FileSource = {
12
17
  async read(path) {
13
- const res = await fetch(`${BASE}game/${path}`);
18
+ const res = await fetch(await gameUrl(path));
14
19
  return res.ok ? new Uint8Array(await res.arrayBuffer()) : undefined;
15
20
  },
16
21
  async readRange(path, offset, length) {
17
- const res = await fetch(`${BASE}game/${path}`, { headers: { Range: `bytes=${offset}-${offset + length - 1}` } });
22
+ const res = await fetch(await gameUrl(path), { headers: { Range: `bytes=${offset}-${offset + length - 1}` } });
18
23
  const bytes = new Uint8Array(await res.arrayBuffer());
19
24
  // A server that ignores Range (Python's http.server) sends the whole file.
20
25
  return res.status === 206 ? bytes : bytes.subarray(offset, offset + length);
21
26
  },
22
27
  async list(dir) {
23
28
  if (import.meta.env.DEV) return (await fetch(`/__list?dir=${encodeURIComponent(dir)}`)).json();
24
- staticListing ??= fetch(`${BASE}game/files.json`).then((r) => (r.ok ? r.json() : {}));
25
- return (await staticListing)[dir] ?? [];
29
+ const files = (await staticListing())[dir];
30
+ return Array.isArray(files) ? files : [];
26
31
  },
27
32
  };
28
- let staticListing: Promise<Record<string, string[]>> | undefined;
33
+
34
+ let listing: Promise<Record<string, unknown>> | undefined;
35
+ const staticListing = () => (listing ??= fetch(`${BASE}game/files.json`, { cache: "no-store" }).then((r): Promise<Record<string, unknown>> => (r.ok ? r.json() : Promise.resolve({}))));
36
+
37
+ /** A game file's URL: on a built site, with the build's version (see httpFiles). */
38
+ async function gameUrl(path: string): Promise<string> {
39
+ const url = `${BASE}game/${path}`;
40
+ if (import.meta.env.DEV) return url;
41
+ const version = (await staticListing()).version;
42
+ return typeof version === "string" ? `${url}?v=${encodeURIComponent(version)}` : url;
43
+ }
29
44
 
30
45
  // --- Mods -------------------------------------------------------------------------------
31
46
 
package/lib/993.sc CHANGED
@@ -5,13 +5,15 @@
5
5
 
6
6
  ;; Something the hero can carry. Its view's loop 0 is its icon in the inventory window and
7
7
  ;; loop 1 the cursor while it's being used (the cel's anchor is the hotspot); both anchored
8
- ;; at the top-left corner. Using it on something sends that thing doVerb: with the item's
8
+ ;; at the top-left corner. One that magnifies (a lens) has its glass as loop 2, placed like
9
+ ;; the cursor: while it's in use, the room shows through the glass `magnify` times larger. Using it on something sends that thing doVerb: with the item's
9
10
  ;; own verb, 10 and up, so the room answers with noun and that verb (in Yarn, a node like
10
11
  ;; filings.lens: the game names its items' verbs in items.yaml).
11
12
  (class InvItem of Obj
12
13
  (properties
13
14
  view -1
14
15
  verb 0
16
+ magnify 0 ; 0, or how much bigger things look through it
15
17
  description 0) ; what looking at it in the window says
16
18
 
17
19
  ;; Looked at in the inventory window.
package/lib/995.sc CHANGED
@@ -65,7 +65,11 @@
65
65
  (if (or (!= v cursor) (!= l cursorLoop))
66
66
  (= cursor v)
67
67
  (= cursorLoop l)
68
- (SetCursor v l 0)))
68
+ (SetCursor v l 0)
69
+ (if (and (== l 1) theItem (theItem magnify?))
70
+ (AddMagnify v 2 0 (theItem magnify?))
71
+ else
72
+ (DeleteMagnify))))
69
73
 
70
74
  (method (doit)
71
75
  (self showCursor:)
package/lib/996.sc CHANGED
@@ -233,18 +233,47 @@
233
233
  (= mouth ((Prop new:) view: view yourself:))
234
234
  (= eyes ((Prop new:) view: view yourself:))))
235
235
 
236
- ;; This line's side: see above.
237
- (method (pickSide &tmp s)
238
- (if (== self heroTalker) (return (- 1 otherSide)))
239
- (= s (if (and who ego (< (who x?) (ego x?))) 0 else 1))
240
- (if (and heroTalker (!= heroTalker self) (heroTalker respondsTo: #otherSide)) (heroTalker otherSide: s))
236
+ ;; This line's side: see above. A side whose portrait would cover the speaker or the hero
237
+ ;; (a face under the frame) gives way to the other, if that one covers no one.
238
+ (method (pickSide face &tmp s)
239
+ (= s
240
+ (cond
241
+ ((== self heroTalker) (- 1 otherSide))
242
+ ((and who ego (< (who x?) (ego x?))) 0)
243
+ (else 1)))
244
+ (if (and face (self covers: face s) (not (self covers: face (- 1 s)))) (= s (- 1 s)))
245
+ (if (and (!= self heroTalker) heroTalker (heroTalker respondsTo: #otherSide)) (heroTalker otherSide: s))
241
246
  (return s))
242
247
 
248
+ ;; Whether the portrait on side s (frame and all) would be over the speaker or the hero.
249
+ (method (covers face s &tmp pw ph over left right bottom)
250
+ (= pw (CelWide (face view?) 0 0))
251
+ (= ph (CelHigh (face view?) 0 0))
252
+ (= over (if (!= (textStyle portraitFrame?) -1) (/ (- (CelWide (textStyle portraitFrame?) 0 0) pw) 2) else 0))
253
+ (= left (if s (- SCREEN_WIDTH (+ (textStyle portraitX?) pw over)) else (- (textStyle portraitX?) over)))
254
+ (= right (+ left pw over over))
255
+ (= bottom (+ (textStyle portraitY?) ph over))
256
+ (return
257
+ (or
258
+ (self over: (if (== self heroTalker) ego else who) left right bottom)
259
+ (and (!= self heroTalker) (self over: ego left right bottom)))))
260
+
261
+ ;; Whether someone's figure reaches into the screen's top band from left to right, down to bottom.
262
+ (method (over body left right bottom &tmp w h)
263
+ (if (or (not body) (not (body respondsTo: #view)) (& (body signal?) SIG_HIDDEN)) (return FALSE))
264
+ (= w (CelWide (body view?) (body loop?) (body cel?)))
265
+ (= h (CelHigh (body view?) (body loop?) (body cel?)))
266
+ (if (& (body scaleSignal?) SCALE_ON)
267
+ (= w (/ (* w (body scaleX?)) 128))
268
+ (= h (/ (* h (body scaleY?)) 128)))
269
+ (return
270
+ (and (< (- (body x?) (/ w 2)) right) (> (+ (body x?) (/ w 2)) left) (< (- (body y?) h) bottom))))
271
+
243
272
  (method (say txt whoCares &tmp face l pw f fx fy over)
244
273
  (self init: makeParts:)
245
274
  (= face (if frame frame else bust))
246
275
  (if face
247
- (= side (self pickSide:))
276
+ (= side (self pickSide: face))
248
277
  (= l (if (and side (>= (NumLoops face) 6)) 3 else 0))
249
278
  (= pw (CelWide (face view?) l 0))
250
279
  (= f (textStyle portraitFrame?))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sci2-ts",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "An SCI2 adventure game engine in TypeScript, and the tools to make games with it",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -56,6 +56,16 @@ export declare class Graphics {
56
56
  x: number;
57
57
  y: number;
58
58
  };
59
+ /**
60
+ * The cursor as a magnifying glass (AddMagnify): where this cel is opaque, placed like the
61
+ * cursor, the screen under the cursor's hotspot shows `zoom` times larger.
62
+ */
63
+ magnify: {
64
+ view: number;
65
+ loop: number;
66
+ cel: number;
67
+ zoom: number;
68
+ } | undefined;
59
69
  /** Palette range affected by fade show styles (SetPalStyleRange). */
60
70
  styleRange: {
61
71
  from: number;
@@ -85,6 +85,11 @@ export class Graphics {
85
85
  transitions = new Map();
86
86
  /** The mouse cursor: a view cel drawn on top at the mouse position (its origin = hotspot). */
87
87
  cursor = { view: -1, loop: 0, cel: 0, visible: true, x: 160, y: 100 };
88
+ /**
89
+ * The cursor as a magnifying glass (AddMagnify): where this cel is opaque, placed like the
90
+ * cursor, the screen under the cursor's hotspot shows `zoom` times larger.
91
+ */
92
+ magnify;
88
93
  /** Palette range affected by fade show styles (SetPalStyleRange). */
89
94
  styleRange = { from: 0, to: 255 };
90
95
  lastFrame;
@@ -218,8 +223,28 @@ export class Graphics {
218
223
  for (const d of draws)
219
224
  blit(pixels, d, clip, remap);
220
225
  }
221
- // The cursor goes on top of everything.
226
+ // The cursor goes on top of everything: first what a magnifier shows through its glass.
222
227
  const cur = this.cursor;
228
+ const mag = this.magnify;
229
+ const glass = cur.visible && mag ? this.cel(mag.view, mag.loop, mag.cel) : undefined;
230
+ if (glass && mag) {
231
+ const scene = pixels.slice();
232
+ const o = celOrigin(glass.cel, glass.mirror);
233
+ const { width: w, height: h, skipColor } = glass.cel;
234
+ for (let gy = 0; gy < h; gy++) {
235
+ for (let gx = 0; gx < w; gx++) {
236
+ if (glass.cel.pixels[gy * w + (glass.mirror ? w - 1 - gx : gx)] === skipColor)
237
+ continue;
238
+ const x = cur.x - o.x + gx, y = cur.y - o.y + gy;
239
+ if (x < 0 || y < 0 || x >= SCREEN_WIDTH || y >= SCREEN_HEIGHT)
240
+ continue;
241
+ const sx = cur.x + Math.floor((x - cur.x) / mag.zoom), sy = cur.y + Math.floor((y - cur.y) / mag.zoom);
242
+ if (sx < 0 || sy < 0 || sx >= SCREEN_WIDTH || sy >= SCREEN_HEIGHT)
243
+ continue;
244
+ pixels[y * SCREEN_WIDTH + x] = scene[sy * SCREEN_WIDTH + sx];
245
+ }
246
+ }
247
+ }
223
248
  if (cur.visible && cur.view >= 0) {
224
249
  const found = this.cel(cur.view, cur.loop, cur.cel);
225
250
  if (found) {
@@ -467,8 +492,17 @@ export const graphicsKernels = {
467
492
  HaveMouse: () => 1,
468
493
  ShakeScreen: () => 0,
469
494
  SetScroll: () => 0,
470
- AddMagnify: () => 0,
471
- DeleteMagnify: () => 0,
495
+ // AddMagnify(view, loop, cel, zoom): sci-ts makes the cursor a magnifying glass, the cel's
496
+ // opaque pixels its glass (placed like the cursor), showing what's under the hotspot zoom
497
+ // times larger. DeleteMagnify() stops it.
498
+ AddMagnify: (vm, [view = 0, loop = 0, cel = 0, zoom = 2]) => {
499
+ graphics(vm).magnify = { view: toSigned(view), loop, cel, zoom: Math.max(1, toSigned(zoom)) };
500
+ return 0;
501
+ },
502
+ DeleteMagnify: (vm) => {
503
+ graphics(vm).magnify = undefined;
504
+ return 0;
505
+ },
472
506
  GetHighItemPri: () => 0,
473
507
  SetFontRes: () => 0,
474
508
  NULL_: () => NULL,
@@ -90,6 +90,11 @@ export class Graphics {
90
90
  readonly transitions = new Map<Value, Transition>();
91
91
  /** The mouse cursor: a view cel drawn on top at the mouse position (its origin = hotspot). */
92
92
  cursor = { view: -1, loop: 0, cel: 0, visible: true, x: 160, y: 100 };
93
+ /**
94
+ * The cursor as a magnifying glass (AddMagnify): where this cel is opaque, placed like the
95
+ * cursor, the screen under the cursor's hotspot shows `zoom` times larger.
96
+ */
97
+ magnify: { view: number; loop: number; cel: number; zoom: number } | undefined;
93
98
  /** Palette range affected by fade show styles (SetPalStyleRange). */
94
99
  styleRange = { from: 0, to: 255 };
95
100
  lastFrame: Frame | undefined;
@@ -224,8 +229,25 @@ export class Graphics {
224
229
  }
225
230
  for (const d of draws) blit(pixels, d, clip, remap);
226
231
  }
227
- // The cursor goes on top of everything.
232
+ // The cursor goes on top of everything: first what a magnifier shows through its glass.
228
233
  const cur = this.cursor;
234
+ const mag = this.magnify;
235
+ const glass = cur.visible && mag ? this.cel(mag.view, mag.loop, mag.cel) : undefined;
236
+ if (glass && mag) {
237
+ const scene = pixels.slice();
238
+ const o = celOrigin(glass.cel, glass.mirror);
239
+ const { width: w, height: h, skipColor } = glass.cel;
240
+ for (let gy = 0; gy < h; gy++) {
241
+ for (let gx = 0; gx < w; gx++) {
242
+ if (glass.cel.pixels[gy * w + (glass.mirror ? w - 1 - gx : gx)] === skipColor) continue;
243
+ const x = cur.x - o.x + gx, y = cur.y - o.y + gy;
244
+ if (x < 0 || y < 0 || x >= SCREEN_WIDTH || y >= SCREEN_HEIGHT) continue;
245
+ const sx = cur.x + Math.floor((x - cur.x) / mag.zoom), sy = cur.y + Math.floor((y - cur.y) / mag.zoom);
246
+ if (sx < 0 || sy < 0 || sx >= SCREEN_WIDTH || sy >= SCREEN_HEIGHT) continue;
247
+ pixels[y * SCREEN_WIDTH + x] = scene[sy * SCREEN_WIDTH + sx]!;
248
+ }
249
+ }
250
+ }
229
251
  if (cur.visible && cur.view >= 0) {
230
252
  const found = this.cel(cur.view, cur.loop, cur.cel);
231
253
  if (found) {
@@ -501,8 +523,17 @@ export const graphicsKernels: Record<string, KernelFn> = {
501
523
  HaveMouse: () => 1,
502
524
  ShakeScreen: () => 0,
503
525
  SetScroll: () => 0,
504
- AddMagnify: () => 0,
505
- DeleteMagnify: () => 0,
526
+ // AddMagnify(view, loop, cel, zoom): sci-ts makes the cursor a magnifying glass, the cel's
527
+ // opaque pixels its glass (placed like the cursor), showing what's under the hotspot zoom
528
+ // times larger. DeleteMagnify() stops it.
529
+ AddMagnify: (vm, [view = 0, loop = 0, cel = 0, zoom = 2]) => {
530
+ graphics(vm).magnify = { view: toSigned(view), loop, cel, zoom: Math.max(1, toSigned(zoom)) };
531
+ return 0;
532
+ },
533
+ DeleteMagnify: (vm) => {
534
+ graphics(vm).magnify = undefined;
535
+ return 0;
536
+ },
506
537
  GetHighItemPri: () => 0,
507
538
  SetFontRes: () => 0,
508
539
  NULL_: () => NULL,
@@ -60,12 +60,14 @@ export interface GameItem {
60
60
  verb: number;
61
61
  view?: number;
62
62
  description?: string;
63
+ /** How much bigger things look through it (a lens): its view's loop 2 is the glass. */
64
+ magnify?: number;
63
65
  }
64
66
  /**
65
67
  * items.yaml: each thing the hero can carry, by name. `lens: 10` names just its verb (10 to
66
68
  * 255; 1 to 4 are the player's), for a game whose scripts make the item; with a view, the
67
69
  * build makes it too, in script ITEMS_SCRIPT, and rooms can give it (`<<get lens>>`):
68
70
  *
69
- * lens: { verb: 10, view: 250, description: "Holmes's lens." }
71
+ * lens: { verb: 10, view: 250, description: "Holmes's lens.", magnify: 2 }
70
72
  */
71
73
  export declare function gameItems(dir: string): Record<string, GameItem>;
@@ -340,7 +340,7 @@ export async function buildGame(dir, options = {}) {
340
340
  * 255; 1 to 4 are the player's), for a game whose scripts make the item; with a view, the
341
341
  * build makes it too, in script ITEMS_SCRIPT, and rooms can give it (`<<get lens>>`):
342
342
  *
343
- * lens: { verb: 10, view: 250, description: "Holmes's lens." }
343
+ * lens: { verb: 10, view: 250, description: "Holmes's lens.", magnify: 2 }
344
344
  */
345
345
  export function gameItems(dir) {
346
346
  const file = join(dir, "items.yaml");
@@ -366,7 +366,7 @@ export function gameItems(dir) {
366
366
  const spec = typeof value === "number" ? { verb: value } : value;
367
367
  if (!spec || typeof spec !== "object" || Array.isArray(spec))
368
368
  return fail(`${name}: expected a verb, or { verb, view, description }`);
369
- const { verb, view, description, ...rest } = spec;
369
+ const { verb, view, description, magnify, ...rest } = spec;
370
370
  const unknown = Object.keys(rest)[0];
371
371
  if (unknown)
372
372
  fail(`${name}: unknown key ${unknown}`);
@@ -380,7 +380,11 @@ export function gameItems(dir) {
380
380
  fail(`${name}: description is text without quotes or backslashes`);
381
381
  if (description !== undefined && view === undefined)
382
382
  fail(`${name}: an item the build makes needs a view`);
383
- items[name] = { verb: verb, view: view, description: description };
383
+ if (magnify !== undefined && (typeof magnify !== "number" || !Number.isInteger(magnify) || magnify < 2 || magnify > 8))
384
+ fail(`${name}: magnify is how much bigger, 2 to 8`);
385
+ if (magnify !== undefined && view === undefined)
386
+ fail(`${name}: an item that magnifies needs a view (its loop 2 is the glass)`);
387
+ items[name] = { verb: verb, view: view, description: description, ...(magnify === undefined ? {} : { magnify: magnify }) };
384
388
  }
385
389
  return items;
386
390
  }
@@ -394,7 +398,7 @@ function itemsScript(items) {
394
398
  `(script ${ITEMS_SCRIPT})`,
395
399
  '(include "system.sh")',
396
400
  `(public ${made.map(([name], i) => `${name} ${i}`).join(" ")})`,
397
- ...made.map(([name, i]) => `(instance ${name} of InvItem (properties view ${i.view} verb ${i.verb}${i.description ? ` description "${i.description}"` : ""}))`),
401
+ ...made.map(([name, i]) => `(instance ${name} of InvItem (properties view ${i.view} verb ${i.verb}${i.magnify ? ` magnify ${i.magnify}` : ""}${i.description ? ` description "${i.description}"` : ""}))`),
398
402
  "",
399
403
  ].join("\n");
400
404
  }
@@ -370,6 +370,8 @@ export interface GameItem {
370
370
  verb: number;
371
371
  view?: number;
372
372
  description?: string;
373
+ /** How much bigger things look through it (a lens): its view's loop 2 is the glass. */
374
+ magnify?: number;
373
375
  }
374
376
 
375
377
  /**
@@ -377,7 +379,7 @@ export interface GameItem {
377
379
  * 255; 1 to 4 are the player's), for a game whose scripts make the item; with a view, the
378
380
  * build makes it too, in script ITEMS_SCRIPT, and rooms can give it (`<<get lens>>`):
379
381
  *
380
- * lens: { verb: 10, view: 250, description: "Holmes's lens." }
382
+ * lens: { verb: 10, view: 250, description: "Holmes's lens.", magnify: 2 }
381
383
  */
382
384
  export function gameItems(dir: string): Record<string, GameItem> {
383
385
  const file = join(dir, "items.yaml");
@@ -394,7 +396,7 @@ export function gameItems(dir: string): Record<string, GameItem> {
394
396
  if (!/^[a-z][A-Za-z0-9]*$/.test(name) || ["look", "talk", "walk", "do", "enter"].includes(name)) fail(`"${name}" can't name an item (lower camelCase, and not a verb)`);
395
397
  const spec = typeof value === "number" ? { verb: value } : value;
396
398
  if (!spec || typeof spec !== "object" || Array.isArray(spec)) return fail(`${name}: expected a verb, or { verb, view, description }`);
397
- const { verb, view, description, ...rest } = spec as Record<string, unknown>;
399
+ const { verb, view, description, magnify, ...rest } = spec as Record<string, unknown>;
398
400
  const unknown = Object.keys(rest)[0];
399
401
  if (unknown) fail(`${name}: unknown key ${unknown}`);
400
402
  if (typeof verb !== "number" || !Number.isInteger(verb) || verb < 10 || verb > 255) fail(`${name}: the verb is a number from 10 to 255`);
@@ -402,7 +404,9 @@ export function gameItems(dir: string): Record<string, GameItem> {
402
404
  if (view !== undefined && (typeof view !== "number" || !Number.isInteger(view) || view < 0 || view > 65535)) fail(`${name}: view is a view number`);
403
405
  if (description !== undefined && (typeof description !== "string" || /["\\]/.test(description))) fail(`${name}: description is text without quotes or backslashes`);
404
406
  if (description !== undefined && view === undefined) fail(`${name}: an item the build makes needs a view`);
405
- items[name] = { verb: verb as number, view: view as number | undefined, description: description as string | undefined };
407
+ if (magnify !== undefined && (typeof magnify !== "number" || !Number.isInteger(magnify) || magnify < 2 || magnify > 8)) fail(`${name}: magnify is how much bigger, 2 to 8`);
408
+ if (magnify !== undefined && view === undefined) fail(`${name}: an item that magnifies needs a view (its loop 2 is the glass)`);
409
+ items[name] = { verb: verb as number, view: view as number | undefined, description: description as string | undefined, ...(magnify === undefined ? {} : { magnify: magnify as number }) };
406
410
  }
407
411
  return items;
408
412
  }
@@ -416,7 +420,7 @@ function itemsScript(items: Record<string, GameItem>): string | undefined {
416
420
  `(script ${ITEMS_SCRIPT})`,
417
421
  '(include "system.sh")',
418
422
  `(public ${made.map(([name], i) => `${name} ${i}`).join(" ")})`,
419
- ...made.map(([name, i]) => `(instance ${name} of InvItem (properties view ${i.view} verb ${i.verb}${i.description ? ` description "${i.description}"` : ""}))`),
423
+ ...made.map(([name, i]) => `(instance ${name} of InvItem (properties view ${i.view} verb ${i.verb}${i.magnify ? ` magnify ${i.magnify}` : ""}${i.description ? ` description "${i.description}"` : ""}))`),
420
424
  "",
421
425
  ].join("\n");
422
426
  }
@@ -6,3 +6,5 @@ export declare function buildSite(gameDir: string, outDir: string, options?: {
6
6
  game: string;
7
7
  soundFont: boolean;
8
8
  }>;
9
+ /** A short hash of the game's files: changes whenever any of them does. */
10
+ export declare function buildVersion(files: Record<string, Uint8Array>): string;
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
2
3
  import { basename, dirname, join, relative, resolve } from "node:path";
3
4
  import { writeResourceArchive } from "../../packages/sci/src/index.js";
@@ -40,7 +41,8 @@ export async function buildSite(gameDir, outDir, options = {}) {
40
41
  for (const [name, data] of Object.entries(files))
41
42
  writeFileSync(join(play, "game", name), data);
42
43
  // What the player lists in place of the dev server's directory listings.
43
- writeFileSync(join(play, "game/files.json"), JSON.stringify({ "": Object.keys(files) }));
44
+ // With the build's version, which the player fetches every file with (apps/viewer/src/files.ts).
45
+ writeFileSync(join(play, "game/files.json"), JSON.stringify({ "": Object.keys(files), version: buildVersion(files) }));
44
46
  const sf2 = [join(resolve(gameDir), SOUNDFONT), join(ROOT, SOUNDFONT)].find((f) => existsSync(f));
45
47
  const soundFont = sf2 !== undefined;
46
48
  if (sf2)
@@ -110,3 +112,10 @@ ${body}
110
112
  </html>
111
113
  `;
112
114
  }
115
+ /** A short hash of the game's files: changes whenever any of them does. */
116
+ export function buildVersion(files) {
117
+ const hash = createHash("sha256");
118
+ for (const name of Object.keys(files).sort())
119
+ hash.update(name).update(files[name]);
120
+ return hash.digest("hex").slice(0, 12);
121
+ }
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
2
3
  import { basename, dirname, join, relative, resolve } from "node:path";
3
4
  import { writeResourceArchive } from "../../packages/sci/src/index.ts";
@@ -46,7 +47,8 @@ export async function buildSite(
46
47
  mkdirSync(join(play, "game"), { recursive: true });
47
48
  for (const [name, data] of Object.entries(files)) writeFileSync(join(play, "game", name), data);
48
49
  // What the player lists in place of the dev server's directory listings.
49
- writeFileSync(join(play, "game/files.json"), JSON.stringify({ "": Object.keys(files) }));
50
+ // With the build's version, which the player fetches every file with (apps/viewer/src/files.ts).
51
+ writeFileSync(join(play, "game/files.json"), JSON.stringify({ "": Object.keys(files), version: buildVersion(files) }));
50
52
  const sf2 = [join(resolve(gameDir), SOUNDFONT), join(ROOT, SOUNDFONT)].find((f) => existsSync(f));
51
53
  const soundFont = sf2 !== undefined;
52
54
  if (sf2) cpSync(sf2, join(play, "soundfonts", basename(sf2)));
@@ -116,3 +118,10 @@ ${body}
116
118
  </html>
117
119
  `;
118
120
  }
121
+
122
+ /** A short hash of the game's files: changes whenever any of them does. */
123
+ export function buildVersion(files: Record<string, Uint8Array>): string {
124
+ const hash = createHash("sha256");
125
+ for (const name of Object.keys(files).sort()) hash.update(name).update(files[name]!);
126
+ return hash.digest("hex").slice(0, 12);
127
+ }