sci2-ts 0.2.2 → 0.4.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +24 -5
  2. package/apps/viewer/src/session.js +12 -2
  3. package/apps/viewer/src/session.ts +12 -2
  4. package/lib/992.sc +173 -0
  5. package/lib/993.sc +135 -0
  6. package/lib/994.sc +13 -0
  7. package/lib/995.sc +64 -5
  8. package/lib/996.sc +88 -26
  9. package/lib/997.sc +26 -0
  10. package/lib/998.sc +43 -5
  11. package/lib/999.sc +4 -1
  12. package/lib/resources.js +33 -3
  13. package/lib/resources.ts +34 -3
  14. package/lib/system.sh +38 -1
  15. package/package.json +1 -1
  16. package/packages/content/src/room.js +13 -1
  17. package/packages/content/src/room.ts +11 -1
  18. package/packages/content/src/target.d.ts +5 -0
  19. package/packages/content/src/target.ts +5 -0
  20. package/packages/sci/src/vm/kernels/index.d.ts +1 -1
  21. package/packages/sci/src/vm/kernels/index.js +1 -1
  22. package/packages/sci/src/vm/kernels/index.ts +1 -1
  23. package/packages/sci/src/vm/kernels/saves.d.ts +8 -0
  24. package/packages/sci/src/vm/kernels/saves.js +24 -3
  25. package/packages/sci/src/vm/kernels/saves.ts +30 -3
  26. package/packages/sci/src/vm/kernels/text.js +27 -2
  27. package/packages/sci/src/vm/kernels/text.ts +23 -2
  28. package/tools/art/build.d.ts +10 -0
  29. package/tools/art/build.js +32 -2
  30. package/tools/art/build.ts +28 -2
  31. package/tools/art/font.d.ts +24 -0
  32. package/tools/art/font.js +40 -0
  33. package/tools/art/font.ts +56 -0
  34. package/tools/game/build.d.ts +4 -0
  35. package/tools/game/build.js +27 -1
  36. package/tools/game/build.ts +24 -1
  37. package/tools/game/kit.d.ts +1 -0
  38. package/tools/game/kit.js +1 -0
  39. package/tools/game/kit.ts +1 -0
  40. package/tools/game/target.d.ts +1 -1
  41. package/tools/game/target.js +5 -3
  42. package/tools/game/target.ts +5 -3
@@ -1,3 +1,4 @@
1
+ import { ResourceType } from "../../resource/types.js";
1
2
  import { restore, snapshot } from "../savegame.js";
2
3
  import { toSigned } from "../value.js";
3
4
  import { stringHelpers } from "./arrays.js";
@@ -26,7 +27,25 @@ const DESCRIPTION_SIZE = 36;
26
27
  class SaveState {
27
28
  store = new MemorySaveStore();
28
29
  restarting = Restarting.None;
30
+ fingerprint;
29
31
  }
32
+ /** A hash (FNV-1a) of every script and heap resource: which scripts a save belongs to. */
33
+ export function scriptsFingerprint(vm) {
34
+ const s = saves(vm);
35
+ if (s.fingerprint === undefined) {
36
+ let h = 0x811c9dc5;
37
+ const resources = vm.resources.list().filter((r) => r.type === ResourceType.Script || r.type === ResourceType.Heap);
38
+ resources.sort((a, b) => a.type - b.type || a.number - b.number);
39
+ for (const r of resources) {
40
+ for (const b of [r.type, r.number & 0xff, r.number >> 8, ...vm.resources.loadSync(r).data])
41
+ h = Math.imul(h ^ b, 0x01000193) >>> 0;
42
+ }
43
+ s.fingerprint = h.toString(16).padStart(8, "0");
44
+ }
45
+ return s.fingerprint;
46
+ }
47
+ /** Whether a save can be restored into the scripts running now. */
48
+ const fits = (vm, info) => info.scripts === undefined || info.scripts === scriptsFingerprint(vm);
30
49
  const stateOf = new WeakMap();
31
50
  export const saves = (vm) => {
32
51
  let s = stateOf.get(vm);
@@ -66,6 +85,7 @@ export const saveKernels = {
66
85
  description: stringHelpers.str(vm, description),
67
86
  version: stringHelpers.str(vm, version),
68
87
  date: Date.now(),
88
+ scripts: scriptsFingerprint(vm),
69
89
  };
70
90
  saves(vm).store.put(info, snapshot(vm));
71
91
  return 1;
@@ -74,7 +94,7 @@ export const saveKernels = {
74
94
  // the saved state via `theGame replay`, as in the original interpreter.
75
95
  RestoreGame: (vm, [, id = 0]) => {
76
96
  const save = saves(vm).store.get(toSigned(id));
77
- if (!save)
97
+ if (!save || !fits(vm, save.info))
78
98
  return 0;
79
99
  restoreGame(vm, save.snapshot);
80
100
  return 1;
@@ -97,7 +117,8 @@ export const saveKernels = {
97
117
  // GetSaveFiles(gameName, descriptions, ids): fixed 36-byte description slots and a
98
118
  // 0-terminated id list, newest first.
99
119
  GetSaveFiles: (vm, [, descriptions = 0, ids = 0]) => {
100
- const list = saves(vm).store.list();
120
+ // Only saves these scripts can restore.
121
+ const list = saves(vm).store.list().filter((info) => fits(vm, info));
101
122
  const desc = stringHelpers.resolve(vm, descriptions);
102
123
  const idArray = stringHelpers.resolve(vm, ids);
103
124
  if (desc) {
@@ -115,7 +136,7 @@ export const saveKernels = {
115
136
  // CheckSaveGame(gameName, id, version): exists and was made by this game version.
116
137
  CheckSaveGame: (vm, [, id = 0, version = 0]) => {
117
138
  const save = saves(vm).store.get(toSigned(id));
118
- return save && save.info.version === stringHelpers.str(vm, version) ? 1 : 0;
139
+ return save && fits(vm, save.info) && save.info.version === stringHelpers.str(vm, version) ? 1 : 0;
119
140
  },
120
141
  GetSaveDir: (vm) => stringHelpers.newString(vm, "SAVES"),
121
142
  MakeSaveCatName: (vm, [dst = 0]) => stringHelpers.writeString(vm, dst, "QG4CDSG.CAT"),
@@ -1,3 +1,4 @@
1
+ import { ResourceType } from "../../resource/types.ts";
1
2
  import { restore, snapshot, type VmSnapshot } from "../savegame.ts";
2
3
  import type { KernelFn, Vm } from "../vm.ts";
3
4
  import { toSigned } from "../value.ts";
@@ -12,6 +13,11 @@ export interface SaveInfo {
12
13
  description: string;
13
14
  version: string;
14
15
  date: number;
16
+ /**
17
+ * The scripts it was made with (scriptsFingerprint). A save is a snapshot of running
18
+ * scripts, so it only restores into the same ones; saves from before this was kept have none.
19
+ */
20
+ scripts?: string;
15
21
  }
16
22
 
17
23
  /** Where saves live. Synchronous because kernels are; hosts preload and write through. */
@@ -47,8 +53,27 @@ const DESCRIPTION_SIZE = 36;
47
53
  class SaveState {
48
54
  store: SaveStore = new MemorySaveStore();
49
55
  restarting: number = Restarting.None;
56
+ fingerprint: string | undefined;
50
57
  }
51
58
 
59
+ /** A hash (FNV-1a) of every script and heap resource: which scripts a save belongs to. */
60
+ export function scriptsFingerprint(vm: Vm): string {
61
+ const s = saves(vm);
62
+ if (s.fingerprint === undefined) {
63
+ let h = 0x811c9dc5;
64
+ const resources = vm.resources.list().filter((r) => r.type === ResourceType.Script || r.type === ResourceType.Heap);
65
+ resources.sort((a, b) => a.type - b.type || a.number - b.number);
66
+ for (const r of resources) {
67
+ for (const b of [r.type, r.number & 0xff, r.number >> 8, ...vm.resources.loadSync(r).data]) h = Math.imul(h ^ b, 0x01000193) >>> 0;
68
+ }
69
+ s.fingerprint = h.toString(16).padStart(8, "0");
70
+ }
71
+ return s.fingerprint;
72
+ }
73
+
74
+ /** Whether a save can be restored into the scripts running now. */
75
+ const fits = (vm: Vm, info: SaveInfo) => info.scripts === undefined || info.scripts === scriptsFingerprint(vm);
76
+
52
77
  const stateOf = new WeakMap<Vm, SaveState>();
53
78
  export const saves = (vm: Vm): SaveState => {
54
79
  let s = stateOf.get(vm);
@@ -90,6 +115,7 @@ export const saveKernels: Record<string, KernelFn> = {
90
115
  description: stringHelpers.str(vm, description),
91
116
  version: stringHelpers.str(vm, version),
92
117
  date: Date.now(),
118
+ scripts: scriptsFingerprint(vm),
93
119
  };
94
120
  saves(vm).store.put(info, snapshot(vm));
95
121
  return 1;
@@ -99,7 +125,7 @@ export const saveKernels: Record<string, KernelFn> = {
99
125
  // the saved state via `theGame replay`, as in the original interpreter.
100
126
  RestoreGame: (vm, [, id = 0]) => {
101
127
  const save = saves(vm).store.get(toSigned(id));
102
- if (!save) return 0;
128
+ if (!save || !fits(vm, save.info)) return 0;
103
129
  restoreGame(vm, save.snapshot);
104
130
  return 1;
105
131
  },
@@ -123,7 +149,8 @@ export const saveKernels: Record<string, KernelFn> = {
123
149
  // GetSaveFiles(gameName, descriptions, ids): fixed 36-byte description slots and a
124
150
  // 0-terminated id list, newest first.
125
151
  GetSaveFiles: (vm, [, descriptions = 0, ids = 0]) => {
126
- const list = saves(vm).store.list();
152
+ // Only saves these scripts can restore.
153
+ const list = saves(vm).store.list().filter((info) => fits(vm, info));
127
154
  const desc = stringHelpers.resolve(vm, descriptions);
128
155
  const idArray = stringHelpers.resolve(vm, ids);
129
156
  if (desc) {
@@ -140,7 +167,7 @@ export const saveKernels: Record<string, KernelFn> = {
140
167
  // CheckSaveGame(gameName, id, version): exists and was made by this game version.
141
168
  CheckSaveGame: (vm, [, id = 0, version = 0]) => {
142
169
  const save = saves(vm).store.get(toSigned(id));
143
- return save && save.info.version === stringHelpers.str(vm, version) ? 1 : 0;
170
+ return save && fits(vm, save.info) && save.info.version === stringHelpers.str(vm, version) ? 1 : 0;
144
171
  },
145
172
 
146
173
  GetSaveDir: (vm) => stringHelpers.newString(vm, "SAVES"),
@@ -330,9 +330,34 @@ export const textKernels = {
330
330
  g.items.delete(obj);
331
331
  return text !== original ? 1 : 0;
332
332
  },
333
- Bitmap: (vm, [subop = 0, ref = 0]) => {
334
- if (subop === 1 && vm.memory.bitmap(ref))
333
+ // Bitmap(1, bitmap): dispose. Bitmap(3, bitmap, view, loop, cel, x, y): draw a view's cel
334
+ // into the bitmap with its origin at (x, y), skipping its transparent pixels (SCI2.1's
335
+ // kBitmapDrawView; the class library frames text boxes with it).
336
+ Bitmap: (vm, [subop = 0, ref = 0, ...args]) => {
337
+ const bmp = vm.memory.bitmap(ref);
338
+ if (subop === 1 && bmp)
335
339
  vm.memory.free(segmentOf(ref));
340
+ if (subop === 3 && bmp) {
341
+ const [view = 0, loop = 0, celNo = 0, x = 0, y = 0] = args.map(toSigned);
342
+ const found = graphics(vm).cel(view, loop, celNo);
343
+ if (!found)
344
+ return 0;
345
+ const { cel, mirror } = found;
346
+ const left = x - ((cel.width >> 1) - cel.displaceX), top = y - (cel.height - 1 - cel.displaceY);
347
+ for (let cy = 0; cy < cel.height; cy++) {
348
+ const by = top + cy;
349
+ if (by < 0 || by >= bmp.height)
350
+ continue;
351
+ for (let cx = 0; cx < cel.width; cx++) {
352
+ const bx = left + cx;
353
+ if (bx < 0 || bx >= bmp.width)
354
+ continue;
355
+ const c = cel.pixels[cy * cel.width + (mirror ? cel.width - 1 - cx : cx)];
356
+ if (c !== cel.skipColor)
357
+ bmp.pixels[by * bmp.width + bx] = c;
358
+ }
359
+ }
360
+ }
336
361
  return 0;
337
362
  },
338
363
  };
@@ -321,8 +321,29 @@ export const textKernels: Record<string, KernelFn> = {
321
321
  return text !== original ? 1 : 0;
322
322
  },
323
323
 
324
- Bitmap: (vm, [subop = 0, ref = 0]) => {
325
- if (subop === 1 && vm.memory.bitmap(ref)) vm.memory.free(segmentOf(ref));
324
+ // Bitmap(1, bitmap): dispose. Bitmap(3, bitmap, view, loop, cel, x, y): draw a view's cel
325
+ // into the bitmap with its origin at (x, y), skipping its transparent pixels (SCI2.1's
326
+ // kBitmapDrawView; the class library frames text boxes with it).
327
+ Bitmap: (vm, [subop = 0, ref = 0, ...args]) => {
328
+ const bmp = vm.memory.bitmap(ref);
329
+ if (subop === 1 && bmp) vm.memory.free(segmentOf(ref));
330
+ if (subop === 3 && bmp) {
331
+ const [view = 0, loop = 0, celNo = 0, x = 0, y = 0] = args.map(toSigned);
332
+ const found = graphics(vm).cel(view, loop, celNo);
333
+ if (!found) return 0;
334
+ const { cel, mirror } = found;
335
+ const left = x - ((cel.width >> 1) - cel.displaceX), top = y - (cel.height - 1 - cel.displaceY);
336
+ for (let cy = 0; cy < cel.height; cy++) {
337
+ const by = top + cy;
338
+ if (by < 0 || by >= bmp.height) continue;
339
+ for (let cx = 0; cx < cel.width; cx++) {
340
+ const bx = left + cx;
341
+ if (bx < 0 || bx >= bmp.width) continue;
342
+ const c = cel.pixels[cy * cel.width + (mirror ? cel.width - 1 - cx : cx)]!;
343
+ if (c !== cel.skipColor) bmp.pixels[by * bmp.width + bx] = c;
344
+ }
345
+ }
346
+ }
326
347
  return 0;
327
348
  },
328
349
  };
@@ -24,6 +24,16 @@ export interface ArtManifest {
24
24
  mirror: boolean;
25
25
  })[];
26
26
  }[];
27
+ /** Fonts drawn as sheets of glyphs (tools/art/font.ts). */
28
+ fonts?: {
29
+ number: number;
30
+ png: string;
31
+ cell: [number, number];
32
+ first?: number;
33
+ spacing?: number;
34
+ space?: number;
35
+ lineHeight?: number;
36
+ }[];
27
37
  }
28
38
  export declare class ArtError extends Error {
29
39
  }
@@ -1,8 +1,9 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { dirname, resolve } from "node:path";
3
- import { ResourceType, writeHunkPalette, writePic, writeView } from "../../packages/sci/src/index.js";
3
+ import { ResourceType, writeFont, writeHunkPalette, writePic, writeView } from "../../packages/sci/src/index.js";
4
4
  import { basePalette } from "../game/defaults.js";
5
5
  import { decodePng } from "../png.js";
6
+ import { FontSheetError, fontFromSheet } from "./font.js";
6
7
  export class ArtError extends Error {
7
8
  }
8
9
  export function buildArt(file) {
@@ -38,7 +39,7 @@ export function buildArt(file) {
38
39
  return fail(where, "expected a file path");
39
40
  return resolve(dirname(file), value);
40
41
  };
41
- const m = object(json(file), "manifest", ["version", "palette", "maxColours", "pictures", "views"]);
42
+ const m = object(json(file), "manifest", ["version", "palette", "maxColours", "pictures", "views", "fonts"]);
42
43
  if (m.version !== 1)
43
44
  fail("version", "expected 1");
44
45
  const maxColours = m.maxColours === undefined ? 254 : int(m.maxColours, "maxColours", 2, 254);
@@ -170,6 +171,35 @@ export function buildArt(file) {
170
171
  fail(where, "a view supports at most 255 stored cels");
171
172
  resources.push({ type: ResourceType.View, number: n, data: writeView({ flags: 1, loops, palette }) });
172
173
  });
174
+ list(m.fonts ?? [], "fonts", 0, 65536).forEach((value, i) => {
175
+ const where = `fonts[${i}]`;
176
+ const f = object(value, where, ["number", "png", "cell", "first", "spacing", "space", "lineHeight"]);
177
+ const n = number(f.number, "font", `${where}.number`);
178
+ const filename = path(f.png, `${where}.png`);
179
+ let img;
180
+ try {
181
+ img = decodePng(readFileSync(filename));
182
+ }
183
+ catch (e) {
184
+ return fail(`${where}.png`, `${filename}: ${e.message}`);
185
+ }
186
+ const cell = list(f.cell, `${where}.cell`, 2, 2);
187
+ const optional = (key, min, max) => (f[key] === undefined ? undefined : int(f[key], `${where}.${key}`, min, max));
188
+ try {
189
+ const font = fontFromSheet(img, {
190
+ cell: [int(cell[0], `${where}.cell[0]`, 1, 128), int(cell[1], `${where}.cell[1]`, 1, 128)],
191
+ first: optional("first", 0, 255), spacing: optional("spacing", 0, 16),
192
+ space: optional("space", 1, 128), lineHeight: optional("lineHeight", 1, 255),
193
+ });
194
+ resources.push({ type: ResourceType.Font, number: n, data: writeFont(font) });
195
+ images++;
196
+ }
197
+ catch (e) {
198
+ if (!(e instanceof FontSheetError))
199
+ throw e;
200
+ fail(`${where}.png`, e.message);
201
+ }
202
+ });
173
203
  resources.sort((a, b) => a.type - b.type || a.number - b.number);
174
204
  return { resources, colours, images };
175
205
  }
@@ -1,8 +1,9 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { dirname, resolve } from "node:path";
3
- import { ResourceType, writeHunkPalette, writePic, writeView, type ResourceData, type ViewLoop } from "../../packages/sci/src/index.ts";
3
+ import { ResourceType, writeFont, writeHunkPalette, writePic, writeView, type ResourceData, type ViewLoop } from "../../packages/sci/src/index.ts";
4
4
  import { basePalette } from "../game/defaults.ts";
5
5
  import { decodePng } from "../png.ts";
6
+ import { FontSheetError, fontFromSheet } from "./font.ts";
6
7
 
7
8
  /** Editor-neutral, exact-colour art for standalone games. See docs/art-workflow.md. */
8
9
  export interface ArtManifest {
@@ -12,6 +13,8 @@ export interface ArtManifest {
12
13
  maxColours?: number;
13
14
  pictures: { number: number; layers: { png: string; priority: number }[] }[];
14
15
  views: { number: number; loops: ({ cels: { png: string; anchor: [number, number] }[] } | { link: number; mirror: boolean })[] }[];
16
+ /** Fonts drawn as sheets of glyphs (tools/art/font.ts). */
17
+ fonts?: { number: number; png: string; cell: [number, number]; first?: number; spacing?: number; space?: number; lineHeight?: number }[];
15
18
  }
16
19
 
17
20
  export class ArtError extends Error {}
@@ -39,7 +42,7 @@ export function buildArt(file: string): { resources: ResourceData[]; colours: st
39
42
  if (typeof value !== "string" || !value.length) return fail(where, "expected a file path");
40
43
  return resolve(dirname(file), value);
41
44
  };
42
- const m = object(json(file), "manifest", ["version", "palette", "maxColours", "pictures", "views"]);
45
+ const m = object(json(file), "manifest", ["version", "palette", "maxColours", "pictures", "views", "fonts"]);
43
46
  if (m.version !== 1) fail("version", "expected 1");
44
47
  const maxColours = m.maxColours === undefined ? 254 : int(m.maxColours, "maxColours", 2, 254);
45
48
  const paletteInput = json(path(m.palette, "palette"));
@@ -148,6 +151,29 @@ export function buildArt(file: string): { resources: ResourceData[]; colours: st
148
151
  if (total > 255) fail(where, "a view supports at most 255 stored cels");
149
152
  resources.push({ type: ResourceType.View, number: n, data: writeView({ flags: 1, loops, palette }) });
150
153
  });
154
+ list(m.fonts ?? [], "fonts", 0, 65536).forEach((value, i) => {
155
+ const where = `fonts[${i}]`;
156
+ const f = object(value, where, ["number", "png", "cell", "first", "spacing", "space", "lineHeight"]);
157
+ const n = number(f.number, "font", `${where}.number`);
158
+ const filename = path(f.png, `${where}.png`);
159
+ let img: ReturnType<typeof decodePng>;
160
+ try { img = decodePng(readFileSync(filename)); }
161
+ catch (e) { return fail(`${where}.png`, `${filename}: ${(e as Error).message}`); }
162
+ const cell = list(f.cell, `${where}.cell`, 2, 2);
163
+ const optional = (key: string, min: number, max: number) => (f[key] === undefined ? undefined : int(f[key], `${where}.${key}`, min, max));
164
+ try {
165
+ const font = fontFromSheet(img, {
166
+ cell: [int(cell[0], `${where}.cell[0]`, 1, 128), int(cell[1], `${where}.cell[1]`, 1, 128)],
167
+ first: optional("first", 0, 255), spacing: optional("spacing", 0, 16),
168
+ space: optional("space", 1, 128), lineHeight: optional("lineHeight", 1, 255),
169
+ });
170
+ resources.push({ type: ResourceType.Font, number: n, data: writeFont(font) });
171
+ images++;
172
+ } catch (e) {
173
+ if (!(e instanceof FontSheetError)) throw e;
174
+ fail(`${where}.png`, e.message);
175
+ }
176
+ });
151
177
  resources.sort((a, b) => a.type - b.type || a.number - b.number);
152
178
  return { resources, colours, images };
153
179
  }
@@ -0,0 +1,24 @@
1
+ import type { Font } from "../../packages/sci/src/index.ts";
2
+ import type { Rgba } from "../png.ts";
3
+ /**
4
+ * A font from a sheet of glyphs drawn in a paint program: a grid of same-size cells, one
5
+ * character per cell, left to right and top to bottom from `first`. A glyph is drawn from
6
+ * its cell's left edge; any opaque pixel is ink (its colour doesn't matter: text takes its
7
+ * colour when it's drawn), and its width is up to its rightmost ink. A cell with no ink is
8
+ * a character the font doesn't have, except the space, which is `space` pixels wide.
9
+ */
10
+ export interface FontSheet {
11
+ /** Cell size in pixels: [width, height]. */
12
+ cell: [number, number];
13
+ /** The character in the first cell (default 32, the space). */
14
+ first?: number;
15
+ /** Pixels between one glyph and the next (default 1). */
16
+ spacing?: number;
17
+ /** Width of the space (default half a cell). */
18
+ space?: number;
19
+ /** Line height (default the cell height). */
20
+ lineHeight?: number;
21
+ }
22
+ export declare class FontSheetError extends Error {
23
+ }
24
+ export declare function fontFromSheet(img: Rgba, sheet: FontSheet): Font;
@@ -0,0 +1,40 @@
1
+ export class FontSheetError extends Error {
2
+ }
3
+ export function fontFromSheet(img, sheet) {
4
+ const [cw, ch] = sheet.cell;
5
+ const first = sheet.first ?? 32, spacing = sheet.spacing ?? 1;
6
+ const space = sheet.space ?? Math.max(1, cw >> 1), lineHeight = sheet.lineHeight ?? ch;
7
+ const fail = (message) => { throw new FontSheetError(message); };
8
+ if (img.width % cw || img.height % ch)
9
+ fail(`${img.width}x${img.height} is not a whole number of ${cw}x${ch} cells`);
10
+ const columns = img.width / cw, cells = columns * (img.height / ch);
11
+ if (first + cells > 256)
12
+ fail(`${cells} cells from character ${first} go past 255`);
13
+ if (lineHeight < 1 || lineHeight > 255)
14
+ fail(`line height ${lineHeight} is outside 1..255`);
15
+ const glyphs = Array.from({ length: Math.max(128, first + cells) }, () => ({ width: 0, height: 0, pixels: new Uint8Array(0) }));
16
+ for (let i = 0; i < cells; i++) {
17
+ const left = (i % columns) * cw, top = Math.floor(i / columns) * ch;
18
+ const ink = new Uint8Array(cw * ch);
19
+ let right = -1;
20
+ for (let y = 0; y < ch; y++) {
21
+ for (let x = 0; x < cw; x++) {
22
+ const a = img.data[((top + y) * img.width + left + x) * 4 + 3];
23
+ if (a !== 0 && a !== 255)
24
+ fail(`character ${first + i} (${left + x},${top + y}): alpha ${a}; export without antialiasing`);
25
+ if (a)
26
+ (ink[y * cw + x] = 1), (right = Math.max(right, x));
27
+ }
28
+ }
29
+ const code = first + i;
30
+ if (right < 0 && code !== 32)
31
+ continue;
32
+ const inked = right + 1, width = (code === 32 && right < 0 ? space : inked + spacing);
33
+ const pixels = new Uint8Array(width * ch);
34
+ for (let y = 0; y < ch; y++)
35
+ for (let x = 0; x < inked; x++)
36
+ pixels[y * width + x] = ink[y * cw + x];
37
+ glyphs[code] = { width, height: ch, pixels };
38
+ }
39
+ return { height: lineHeight, glyphs };
40
+ }
@@ -0,0 +1,56 @@
1
+ import type { Font, Glyph } from "../../packages/sci/src/index.ts";
2
+ import type { Rgba } from "../png.ts";
3
+
4
+ /**
5
+ * A font from a sheet of glyphs drawn in a paint program: a grid of same-size cells, one
6
+ * character per cell, left to right and top to bottom from `first`. A glyph is drawn from
7
+ * its cell's left edge; any opaque pixel is ink (its colour doesn't matter: text takes its
8
+ * colour when it's drawn), and its width is up to its rightmost ink. A cell with no ink is
9
+ * a character the font doesn't have, except the space, which is `space` pixels wide.
10
+ */
11
+ export interface FontSheet {
12
+ /** Cell size in pixels: [width, height]. */
13
+ cell: [number, number];
14
+ /** The character in the first cell (default 32, the space). */
15
+ first?: number;
16
+ /** Pixels between one glyph and the next (default 1). */
17
+ spacing?: number;
18
+ /** Width of the space (default half a cell). */
19
+ space?: number;
20
+ /** Line height (default the cell height). */
21
+ lineHeight?: number;
22
+ }
23
+
24
+ export class FontSheetError extends Error {}
25
+
26
+ export function fontFromSheet(img: Rgba, sheet: FontSheet): Font {
27
+ const [cw, ch] = sheet.cell;
28
+ const first = sheet.first ?? 32, spacing = sheet.spacing ?? 1;
29
+ const space = sheet.space ?? Math.max(1, cw >> 1), lineHeight = sheet.lineHeight ?? ch;
30
+ const fail = (message: string): never => { throw new FontSheetError(message); };
31
+ if (img.width % cw || img.height % ch) fail(`${img.width}x${img.height} is not a whole number of ${cw}x${ch} cells`);
32
+ const columns = img.width / cw, cells = columns * (img.height / ch);
33
+ if (first + cells > 256) fail(`${cells} cells from character ${first} go past 255`);
34
+ if (lineHeight < 1 || lineHeight > 255) fail(`line height ${lineHeight} is outside 1..255`);
35
+
36
+ const glyphs: Glyph[] = Array.from({ length: Math.max(128, first + cells) }, () => ({ width: 0, height: 0, pixels: new Uint8Array(0) }));
37
+ for (let i = 0; i < cells; i++) {
38
+ const left = (i % columns) * cw, top = Math.floor(i / columns) * ch;
39
+ const ink = new Uint8Array(cw * ch);
40
+ let right = -1;
41
+ for (let y = 0; y < ch; y++) {
42
+ for (let x = 0; x < cw; x++) {
43
+ const a = img.data[((top + y) * img.width + left + x) * 4 + 3]!;
44
+ if (a !== 0 && a !== 255) fail(`character ${first + i} (${left + x},${top + y}): alpha ${a}; export without antialiasing`);
45
+ if (a) (ink[y * cw + x] = 1), (right = Math.max(right, x));
46
+ }
47
+ }
48
+ const code = first + i;
49
+ if (right < 0 && code !== 32) continue;
50
+ const inked = right + 1, width = (code === 32 && right < 0 ? space : inked + spacing);
51
+ const pixels = new Uint8Array(width * ch);
52
+ for (let y = 0; y < ch; y++) for (let x = 0; x < inked; x++) pixels[y * width + x] = ink[y * cw + x]!;
53
+ glyphs[code] = { width, height: ch, pixels };
54
+ }
55
+ return { height: lineHeight, glyphs };
56
+ }
@@ -9,6 +9,8 @@ import { type ResourceData } from "../../packages/sci/src/index.ts";
9
9
  * games/<name>/rooms/<n>.room.yaml (+ <n>.yarn) rooms as data (packages/content), compiled
10
10
  * for the class library; their Yarn variables are flags
11
11
  * numbered in flags.yaml
12
+ * games/<name>/items.yaml optional: the verbs of the things the hero can carry
13
+ * (`lens: 10`), so rooms' Yarn can answer them (filings.lens)
12
14
  * games/<name>/music/<n>.mid music: sound n, from a Standard MIDI File (General MIDI)
13
15
  * games/<name>/sounds/<n>.wav digital effects: sound n plays this instead of music
14
16
  * games/<name>/resources.ts optional: `export default () => ResourceData[]` for art,
@@ -44,3 +46,5 @@ export declare const LIBRARY_DIR: string;
44
46
  export declare function buildGame(dir: string, options?: {
45
47
  library?: boolean;
46
48
  }): Promise<BuiltGame>;
49
+ /** items.yaml: `name: verb` lines, each item's verb from 10 to 255 (1 to 4 are the player's). */
50
+ export declare function itemVerbs(dir: string): Record<string, number>;
@@ -108,7 +108,7 @@ export async function buildGame(dir, options = {}) {
108
108
  const roomsDir = join(dir, "rooms");
109
109
  if (!existsSync(roomsDir))
110
110
  return [];
111
- const target = libraryTarget(globals);
111
+ const target = libraryTarget(globals, itemVerbs(dir));
112
112
  const flags = modFlags(dir, { content: target });
113
113
  const out = [];
114
114
  for (const { file, text } of load(roomsDir, /^\d+\.room\.yaml$/)) {
@@ -257,3 +257,29 @@ export async function buildGame(dir, options = {}) {
257
257
  warnings: [...new Set(warnings)],
258
258
  };
259
259
  }
260
+ /** items.yaml: `name: verb` lines, each item's verb from 10 to 255 (1 to 4 are the player's). */
261
+ export function itemVerbs(dir) {
262
+ const file = join(dir, "items.yaml");
263
+ if (!existsSync(file))
264
+ return {};
265
+ const where = relative(process.cwd(), file);
266
+ const verbs = {};
267
+ readFileSync(file, "utf8").split("\n").forEach((raw, i) => {
268
+ const line = raw.replace(/#.*/, "").trim();
269
+ if (!line)
270
+ return;
271
+ const at = `${where}:${i + 1}`;
272
+ const m = /^([A-Za-z]\w*)\s*:\s*(\d+)$/.exec(line);
273
+ if (!m)
274
+ throw new GameBuildError(`${at}: expected item: verb`);
275
+ const [, name, n] = m, verb = Number(n);
276
+ if (!/^[a-z][A-Za-z0-9]*$/.test(name) || ["look", "talk", "walk", "do", "enter"].includes(name))
277
+ throw new GameBuildError(`${at}: "${name}" can't name an item (lower camelCase, and not a verb)`);
278
+ if (verb < 10 || verb > 255)
279
+ throw new GameBuildError(`${at}: ${name}: the verb is a number from 10 to 255`);
280
+ if (Object.values(verbs).includes(verb))
281
+ throw new GameBuildError(`${at}: ${name}: verb ${verb} is used twice`);
282
+ verbs[name] = verb;
283
+ });
284
+ return verbs;
285
+ }
@@ -22,6 +22,8 @@ import { libraryTarget } from "./target.ts";
22
22
  * games/<name>/rooms/<n>.room.yaml (+ <n>.yarn) rooms as data (packages/content), compiled
23
23
  * for the class library; their Yarn variables are flags
24
24
  * numbered in flags.yaml
25
+ * games/<name>/items.yaml optional: the verbs of the things the hero can carry
26
+ * (`lens: 10`), so rooms' Yarn can answer them (filings.lens)
25
27
  * games/<name>/music/<n>.mid music: sound n, from a Standard MIDI File (General MIDI)
26
28
  * games/<name>/sounds/<n>.wav digital effects: sound n plays this instead of music
27
29
  * games/<name>/resources.ts optional: `export default () => ResourceData[]` for art,
@@ -140,7 +142,7 @@ export async function buildGame(dir: string, options: { library?: boolean } = {}
140
142
  const compileRooms = (): { file: string; text: string }[] => {
141
143
  const roomsDir = join(dir, "rooms");
142
144
  if (!existsSync(roomsDir)) return [];
143
- const target = libraryTarget(globals);
145
+ const target = libraryTarget(globals, itemVerbs(dir));
144
146
  const flags = modFlags(dir, { content: target });
145
147
  const out: { file: string; text: string }[] = [];
146
148
  for (const { file, text } of load(roomsDir, /^\d+\.room\.yaml$/)) {
@@ -281,3 +283,24 @@ export async function buildGame(dir: string, options: { library?: boolean } = {}
281
283
  warnings: [...new Set(warnings)],
282
284
  };
283
285
  }
286
+
287
+ /** items.yaml: `name: verb` lines, each item's verb from 10 to 255 (1 to 4 are the player's). */
288
+ export function itemVerbs(dir: string): Record<string, number> {
289
+ const file = join(dir, "items.yaml");
290
+ if (!existsSync(file)) return {};
291
+ const where = relative(process.cwd(), file);
292
+ const verbs: Record<string, number> = {};
293
+ readFileSync(file, "utf8").split("\n").forEach((raw, i) => {
294
+ const line = raw.replace(/#.*/, "").trim();
295
+ if (!line) return;
296
+ const at = `${where}:${i + 1}`;
297
+ const m = /^([A-Za-z]\w*)\s*:\s*(\d+)$/.exec(line);
298
+ if (!m) throw new GameBuildError(`${at}: expected item: verb`);
299
+ const [, name, n] = m, verb = Number(n);
300
+ if (!/^[a-z][A-Za-z0-9]*$/.test(name!) || ["look", "talk", "walk", "do", "enter"].includes(name!)) throw new GameBuildError(`${at}: "${name}" can't name an item (lower camelCase, and not a verb)`);
301
+ if (verb < 10 || verb > 255) throw new GameBuildError(`${at}: ${name}: the verb is a number from 10 to 255`);
302
+ if (Object.values(verbs).includes(verb)) throw new GameBuildError(`${at}: ${name}: verb ${verb} is used twice`);
303
+ verbs[name!] = verb;
304
+ });
305
+ return verbs;
306
+ }
@@ -5,3 +5,4 @@
5
5
  export { ResourceType, SoundDevice, writeFont, writeHunkPalette, writePic, writeSound, writeView, type Cel, type Font, type HunkPaletteFile, type MidiEvent, type PicFile, type ResourceData, type SoundSpec, type ViewFile, } from "../../packages/sci/src/index.ts";
6
6
  export { BASE_PALETTE, CURSOR_VIEW, Colour, DEFAULT_FONT, basePalette, cube } from "./defaults.ts";
7
7
  export { pixelFont } from "./font.ts";
8
+ export { fontFromSheet, type FontSheet } from "../art/font.ts";
package/tools/game/kit.js CHANGED
@@ -5,3 +5,4 @@
5
5
  export { ResourceType, SoundDevice, writeFont, writeHunkPalette, writePic, writeSound, writeView, } from "../../packages/sci/src/index.js";
6
6
  export { BASE_PALETTE, CURSOR_VIEW, Colour, DEFAULT_FONT, basePalette, cube } from "./defaults.js";
7
7
  export { pixelFont } from "./font.js";
8
+ export { fontFromSheet } from "../art/font.js";
package/tools/game/kit.ts CHANGED
@@ -8,3 +8,4 @@ export {
8
8
  } from "../../packages/sci/src/index.ts";
9
9
  export { BASE_PALETTE, CURSOR_VIEW, Colour, DEFAULT_FONT, basePalette, cube } from "./defaults.ts";
10
10
  export { pixelFont } from "./font.ts";
11
+ export { fontFromSheet, type FontSheet } from "../art/font.ts";
@@ -4,4 +4,4 @@ import type { Target } from "../../packages/content/src/index.ts";
4
4
  * (packages/content). Global numbers depend on the game's script 0, so the target is made
5
5
  * for each build from the compiled globals' names.
6
6
  */
7
- export declare function libraryTarget(globals: readonly string[]): Target;
7
+ export declare function libraryTarget(globals: readonly string[], items?: Record<string, number>): Target;
@@ -3,7 +3,7 @@
3
3
  * (packages/content). Global numbers depend on the game's script 0, so the target is made
4
4
  * for each build from the compiled globals' names.
5
5
  */
6
- export function libraryTarget(globals) {
6
+ export function libraryTarget(globals, items = {}) {
7
7
  const g = (name) => {
8
8
  const n = globals.indexOf(name);
9
9
  if (n < 0)
@@ -28,12 +28,14 @@ export function libraryTarget(globals) {
28
28
  firstRoomTalker: 200,
29
29
  voice: { class: "Talker", props: {} },
30
30
  // Portraits stand at the top left, the text beside them.
31
- portrait: { class: "PortraitTalker", props: {}, at: [36, 70], frameSignal: 0, partSignal: 0 },
31
+ portrait: { class: "PortraitTalker", props: {}, at: [8, 8], frameSignal: 0, partSignal: 0 },
32
32
  teller: { class: "Teller", props: {}, verb: 2 },
33
33
  forwardCycle: "Forward",
34
+ closeUp: "CloseUp",
34
35
  // room.enter lines: a verb the player can't use.
35
36
  narrationVerb: 0,
36
- verbs: { look: 1, talk: 2, walk: 3, do: 4 },
37
+ // And the game's items (items.yaml): using one is its own verb.
38
+ verbs: { look: 1, talk: 2, walk: 3, do: 4, ...items },
37
39
  messageVersion: 4321,
38
40
  // SetFlag, ClearFlag and IsFlag: script 999's exports 0, 1 and 2.
39
41
  flags: { script: 999, set: 0, clear: 1, test: 2, first: 0, last: 1023 },
@@ -5,7 +5,7 @@ import type { Target } from "../../packages/content/src/index.ts";
5
5
  * (packages/content). Global numbers depend on the game's script 0, so the target is made
6
6
  * for each build from the compiled globals' names.
7
7
  */
8
- export function libraryTarget(globals: readonly string[]): Target {
8
+ export function libraryTarget(globals: readonly string[], items: Record<string, number> = {}): Target {
9
9
  const g = (name: string) => {
10
10
  const n = globals.indexOf(name);
11
11
  if (n < 0) throw new Error(`the library's global ${name} isn't there: is script 0 compiled with the library?`);
@@ -29,12 +29,14 @@ export function libraryTarget(globals: readonly string[]): Target {
29
29
  firstRoomTalker: 200,
30
30
  voice: { class: "Talker", props: {} },
31
31
  // Portraits stand at the top left, the text beside them.
32
- portrait: { class: "PortraitTalker", props: {}, at: [36, 70], frameSignal: 0, partSignal: 0 },
32
+ portrait: { class: "PortraitTalker", props: {}, at: [8, 8], frameSignal: 0, partSignal: 0 },
33
33
  teller: { class: "Teller", props: {}, verb: 2 },
34
34
  forwardCycle: "Forward",
35
+ closeUp: "CloseUp",
35
36
  // room.enter lines: a verb the player can't use.
36
37
  narrationVerb: 0,
37
- verbs: { look: 1, talk: 2, walk: 3, do: 4 },
38
+ // And the game's items (items.yaml): using one is its own verb.
39
+ verbs: { look: 1, talk: 2, walk: 3, do: 4, ...items },
38
40
  messageVersion: 4321,
39
41
  // SetFlag, ClearFlag and IsFlag: script 999's exports 0, 1 and 2.
40
42
  flags: { script: 999, set: 0, clear: 1, test: 2, first: 0, last: 1023 },