sci2-ts 0.14.0 → 0.15.1

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.
@@ -1,5 +1,6 @@
1
1
  import { ResourceType } from "../../resource/types.ts";
2
- import { parseFont, textWidth, wrapText, type Font } from "../../text/font.ts";
2
+ import { parseFont, type Font } from "../../text/font.ts";
3
+ import { parseStyled, placeLine, styledWidth, wrapStyled } from "../../text/styled.ts";
3
4
  import { hasRef, parseMessages, type MessageFile, type MessageTuple } from "../../text/message.ts";
4
5
  import type { SciBitmap } from "../memory.ts";
5
6
  import type { FrameRequest, KernelFn, SendRequest, Vm } from "../vm.ts";
@@ -106,28 +107,37 @@ const DEFAULT_MAX_WIDTH = (320 * 3) / 5;
106
107
  */
107
108
  export const unescapeText = (s: string) => s.replace(/\\([0-9a-fA-F]{2})/g, (_m, hex: string) => String.fromCharCode(parseInt(hex, 16)));
108
109
 
109
- /** Draws wrapped text into a bitmap, inside `rect`, with alignment. */
110
- function drawText(bmp: SciBitmap, font: Font, str: string, rect: { left: number; top: number; right: number; bottom: number }, color: number, align: number) {
110
+ /**
111
+ * Draws wrapped text into a bitmap, inside `rect`, with alignment. The text can change font
112
+ * and colour (`|f3|`, `|c5|`). Ink can start left of the pen or run past it (an italic's
113
+ * tail) into the box's margin; only the bitmap's edges stop it.
114
+ */
115
+ function drawText(vm: Vm, bmp: SciBitmap, fontNo: number, str: string, rect: { left: number; top: number; right: number; bottom: number }, color: number, align: number) {
116
+ const fontOf = (n: number) => text(vm).font(n);
111
117
  const width = rect.right - rect.left;
112
- const lines = wrapText(font, str, width);
113
- lines.forEach((line, row) => {
114
- const w = textWidth(font, line);
115
- let x = rect.left + (align === TextAlign.Center ? Math.floor((width - w) / 2) : align === TextAlign.Right ? width - w : 0);
116
- const y0 = rect.top + row * font.height;
117
- for (const ch of line) {
118
- const g = font.glyphs[ch.charCodeAt(0)];
119
- if (!g) continue;
118
+ let y0 = rect.top;
119
+ for (const line of wrapStyled(fontOf, parseStyled(str, fontNo), fontNo, width)) {
120
+ const x = rect.left + (align === TextAlign.Center ? Math.floor((width - line.width) / 2) : align === TextAlign.Right ? width - line.width : 0);
121
+ for (const { glyph: g, x: gx0, color: c } of placeLine(fontOf, line, x)) {
122
+ const ink = c ?? color;
120
123
  for (let gy = 0; gy < g.height; gy++) {
121
124
  const y = y0 + gy;
122
125
  if (y < rect.top || y >= rect.bottom || y >= bmp.height) continue;
123
126
  for (let gx = 0; gx < g.width; gx++) {
124
- const px = x + gx;
125
- if (g.pixels[gy * g.width + gx] && px >= 0 && px < bmp.width) bmp.pixels[y * bmp.width + px] = color;
127
+ const px = gx0 + gx;
128
+ if (g.pixels[gy * g.width + gx] && px >= 0 && px < bmp.width) bmp.pixels[y * bmp.width + px] = ink;
126
129
  }
127
130
  }
128
- x += g.width;
129
131
  }
130
- });
132
+ y0 += line.height;
133
+ }
134
+ }
135
+
136
+ /** The size text takes in a font, wrapped to maxWidth: the widest line's advance, and the lines' heights. */
137
+ function measure(vm: Vm, fontNo: number, str: string, maxWidth: number): { width: number; height: number } {
138
+ const fontOf = (n: number) => text(vm).font(n);
139
+ const lines = wrapStyled(fontOf, parseStyled(str, fontNo), fontNo, maxWidth);
140
+ return { width: Math.max(0, ...lines.map((l) => l.width)), height: lines.reduce((h, l) => h + l.height, 0) };
131
141
  }
132
142
 
133
143
  function drawBorder(bmp: SciBitmap, color: number) {
@@ -185,30 +195,33 @@ export const textKernels: Record<string, KernelFn> = {
185
195
 
186
196
  // TextSize(rectArray, text, font, maxWidth): writes [left, top, right, bottom] (inclusive).
187
197
  TextSize: (vm, [rect = 0, str = 0, fontNo = 0, maxWidth = 0]) => {
188
- const font = text(vm).font(fontNo);
189
198
  const s = stringHelpers.str(vm, str);
199
+ const fontOf = (n: number) => text(vm).font(n);
200
+ const oneLine = () => {
201
+ const chars = parseStyled(s, fontNo);
202
+ return { width: styledWidth(fontOf, chars), height: Math.max(fontOf(fontNo).height, ...chars.map((c) => fontOf(c.font).height)) };
203
+ };
190
204
  let w: number, h: number;
191
205
  const max = toSigned(maxWidth);
192
206
  if (max >= 0) {
193
207
  const limit = max || DEFAULT_MAX_WIDTH;
194
- if (!/[\r\n]/.test(s) && textWidth(font, s) <= limit) {
208
+ const one = oneLine();
209
+ if (!/[\r\n]/.test(s) && one.width <= limit) {
195
210
  // Fits on one line: measured as-is, spaces included (a lone " " has a width).
196
- w = textWidth(font, s);
197
- h = font.height;
211
+ ({ width: w, height: h } = one);
198
212
  } else {
199
- const lines = wrapText(font, s, limit);
200
- w = Math.min(limit, Math.max(0, ...lines.map((l) => textWidth(font, l))));
201
- h = lines.length * font.height;
213
+ const m = measure(vm, fontNo, s, limit);
214
+ w = Math.min(limit, m.width);
215
+ h = m.height;
202
216
  }
203
217
  } else {
204
- w = textWidth(font, s);
205
- h = font.height;
218
+ ({ width: w, height: h } = oneLine());
206
219
  }
207
220
  const a = stringHelpers.resolve(vm, rect);
208
221
  if (a) a.data.splice(0, 4, 0, 0, fromInt(w - 1), fromInt(h - 1));
209
222
  return 0;
210
223
  },
211
- TextWidth: (vm, [str = 0, fontNo = 0]) => textWidth(text(vm).font(fontNo), stringHelpers.str(vm, str)),
224
+ TextWidth: (vm, [str = 0, fontNo = 0]) => styledWidth((n) => text(vm).font(n), parseStyled(stringHelpers.str(vm, str), fontNo)),
212
225
  PointSize: (vm, [fontNo = 0]) => text(vm).font(fontNo).height,
213
226
  TextFonts: () => 0,
214
227
  TextColors: () => 0,
@@ -221,7 +234,7 @@ export const textKernels: Record<string, KernelFn> = {
221
234
  if (!vm.memory.object(obj)) return NULL;
222
235
  const w = Math.max(1, toSigned(width)), h = Math.max(1, toSigned(height));
223
236
  const str = unescapeText(stringHelpers.str(vm, vm.getProp(obj, "text") ?? 0));
224
- const font = text(vm).font(prop(vm, obj, "font"));
237
+ const fontNo = prop(vm, obj, "font");
225
238
  const fore = prop(vm, obj, "fore") & 0xff;
226
239
  const align = prop(vm, obj, "mode");
227
240
  const rect = {
@@ -247,7 +260,7 @@ export const textKernels: Record<string, KernelFn> = {
247
260
  ref = vm.memory.newBitmap(cw, ch, found?.cel.skipColor ?? 0, found?.cel.skipColor ?? 0);
248
261
  if (found) vm.memory.bitmap(ref)!.pixels.set(found.cel.pixels);
249
262
  }
250
- drawText(vm.memory.bitmap(ref)!, font, str, rect, fore, align);
263
+ drawText(vm, vm.memory.bitmap(ref)!, fontNo, str, rect, fore, align);
251
264
  return ref;
252
265
  },
253
266
 
@@ -295,7 +308,7 @@ export const textKernels: Record<string, KernelFn> = {
295
308
  const bmp = vm.memory.bitmap(bitmap)!;
296
309
  if (border >= 0) drawBorder(bmp, border & 0xff);
297
310
  const cursor = (frame >> 4) & 1 ? "" : "_"; // blink
298
- drawText(bmp, font, text + cursor, rect, fore, TextAlign.Left);
311
+ drawText(vm, bmp, prop(vm, obj, "font"), text + cursor, rect, fore, TextAlign.Left);
299
312
  vm.setProp(obj, "bitmap", bitmap);
300
313
  graphicsKernels.FrameOut!(vm, []);
301
314
  yield { frame: true };
@@ -24,8 +24,11 @@ export interface ArtManifest {
24
24
  mirror: boolean;
25
25
  })[];
26
26
  }[];
27
- /** Fonts drawn as sheets of glyphs (tools/art/font.ts). */
28
- fonts?: {
27
+ /**
28
+ * Fonts (tools/art/font.ts): drawn as sheets of glyphs, or a bitmap font with its own
29
+ * metrics, a JSON file (bearings, advances, its atlas) exported from the font's source.
30
+ */
31
+ fonts?: ({
29
32
  number: number;
30
33
  png: string;
31
34
  cell: [number, number];
@@ -33,7 +36,10 @@ export interface ArtManifest {
33
36
  spacing?: number;
34
37
  space?: number;
35
38
  lineHeight?: number;
36
- }[];
39
+ } | {
40
+ number: number;
41
+ metrics: string;
42
+ })[];
37
43
  }
38
44
  export declare class ArtError extends Error {
39
45
  }
@@ -3,7 +3,7 @@ import { dirname, resolve } from "node:path";
3
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
+ import { FontSheetError, fontFromMetrics, fontFromSheet } from "./font.js";
7
7
  export class ArtError extends Error {
8
8
  }
9
9
  export function buildArt(file) {
@@ -173,8 +173,39 @@ export function buildArt(file) {
173
173
  });
174
174
  list(m.fonts ?? [], "fonts", 0, 65536).forEach((value, i) => {
175
175
  const where = `fonts[${i}]`;
176
- const f = object(value, where, ["number", "png", "cell", "first", "spacing", "space", "lineHeight"]);
176
+ const f = object(value, where, ["number", "png", "cell", "first", "spacing", "space", "lineHeight", "metrics"]);
177
177
  const n = number(f.number, "font", `${where}.number`);
178
+ // A bitmap font with its own metrics: a sidecar JSON naming its atlas.
179
+ if (f.metrics !== undefined) {
180
+ if (f.png !== undefined || f.cell !== undefined)
181
+ return fail(where, "a font is a sheet (png, cell) or has metrics, not both");
182
+ const metricsFile = path(f.metrics, `${where}.metrics`);
183
+ let metrics;
184
+ try {
185
+ metrics = JSON.parse(readFileSync(metricsFile, "utf8"));
186
+ }
187
+ catch (e) {
188
+ return fail(`${where}.metrics`, `${metricsFile}: ${e.message}`);
189
+ }
190
+ const atlas = resolve(dirname(metricsFile), String(metrics.atlas));
191
+ let img;
192
+ try {
193
+ img = decodePng(readFileSync(atlas));
194
+ }
195
+ catch (e) {
196
+ return fail(`${where}.metrics`, `${atlas}: ${e.message}`);
197
+ }
198
+ try {
199
+ resources.push({ type: ResourceType.Font, number: n, data: writeFont(fontFromMetrics(img, metrics)) });
200
+ images++;
201
+ }
202
+ catch (e) {
203
+ if (!(e instanceof FontSheetError))
204
+ throw e;
205
+ fail(`${where}.metrics`, e.message);
206
+ }
207
+ return;
208
+ }
178
209
  const filename = path(f.png, `${where}.png`);
179
210
  let img;
180
211
  try {
@@ -3,7 +3,7 @@ import { dirname, resolve } from "node:path";
3
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
+ import { FontSheetError, fontFromMetrics, fontFromSheet, type FontMetrics } from "./font.ts";
7
7
 
8
8
  /** Editor-neutral, exact-colour art for standalone games. See docs/art-workflow.md. */
9
9
  export interface ArtManifest {
@@ -13,8 +13,11 @@ export interface ArtManifest {
13
13
  maxColours?: number;
14
14
  pictures: { number: number; layers: { png: string; priority: number }[] }[];
15
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 }[];
16
+ /**
17
+ * Fonts (tools/art/font.ts): drawn as sheets of glyphs, or a bitmap font with its own
18
+ * metrics, a JSON file (bearings, advances, its atlas) exported from the font's source.
19
+ */
20
+ fonts?: ({ number: number; png: string; cell: [number, number]; first?: number; spacing?: number; space?: number; lineHeight?: number } | { number: number; metrics: string })[];
18
21
  }
19
22
 
20
23
  export class ArtError extends Error {}
@@ -153,8 +156,28 @@ export function buildArt(file: string): { resources: ResourceData[]; colours: st
153
156
  });
154
157
  list(m.fonts ?? [], "fonts", 0, 65536).forEach((value, i) => {
155
158
  const where = `fonts[${i}]`;
156
- const f = object(value, where, ["number", "png", "cell", "first", "spacing", "space", "lineHeight"]);
159
+ const f = object(value, where, ["number", "png", "cell", "first", "spacing", "space", "lineHeight", "metrics"]);
157
160
  const n = number(f.number, "font", `${where}.number`);
161
+ // A bitmap font with its own metrics: a sidecar JSON naming its atlas.
162
+ if (f.metrics !== undefined) {
163
+ if (f.png !== undefined || f.cell !== undefined) return fail(where, "a font is a sheet (png, cell) or has metrics, not both");
164
+ const metricsFile = path(f.metrics, `${where}.metrics`);
165
+ let metrics: FontMetrics;
166
+ try { metrics = JSON.parse(readFileSync(metricsFile, "utf8")) as FontMetrics; }
167
+ catch (e) { return fail(`${where}.metrics`, `${metricsFile}: ${(e as Error).message}`); }
168
+ const atlas = resolve(dirname(metricsFile), String(metrics.atlas));
169
+ let img: ReturnType<typeof decodePng>;
170
+ try { img = decodePng(readFileSync(atlas)); }
171
+ catch (e) { return fail(`${where}.metrics`, `${atlas}: ${(e as Error).message}`); }
172
+ try {
173
+ resources.push({ type: ResourceType.Font, number: n, data: writeFont(fontFromMetrics(img, metrics)) });
174
+ images++;
175
+ } catch (e) {
176
+ if (!(e instanceof FontSheetError)) throw e;
177
+ fail(`${where}.metrics`, e.message);
178
+ }
179
+ return;
180
+ }
158
181
  const filename = path(f.png, `${where}.png`);
159
182
  let img: ReturnType<typeof decodePng>;
160
183
  try { img = decodePng(readFileSync(filename)); }
@@ -22,3 +22,23 @@ export interface FontSheet {
22
22
  export declare class FontSheetError extends Error {
23
23
  }
24
24
  export declare function fontFromSheet(img: Rgba, sheet: FontSheet): Font;
25
+ /**
26
+ * A font from a bitmap font's own metrics (sidecar JSON next to an atlas, as exported from
27
+ * BDF): each glyph's rectangle in the atlas, where its top sits below the line's top, its
28
+ * bearing from the pen and its advance. Nothing is trimmed, moved or respaced: the glyph is
29
+ * the rectangle's ink, with blank rows above it down to its top.
30
+ */
31
+ export interface FontMetrics {
32
+ lineHeight: number;
33
+ first: number;
34
+ last: number;
35
+ atlas: string;
36
+ glyphs: {
37
+ code: number;
38
+ rect: [number, number, number, number];
39
+ bearingX: number;
40
+ top: number;
41
+ advance: number;
42
+ }[];
43
+ }
44
+ export declare function fontFromMetrics(img: Rgba, m: FontMetrics): Font;
package/tools/art/font.js CHANGED
@@ -38,3 +38,38 @@ export function fontFromSheet(img, sheet) {
38
38
  }
39
39
  return { height: lineHeight, glyphs };
40
40
  }
41
+ export function fontFromMetrics(img, m) {
42
+ const fail = (message) => { throw new FontSheetError(message); };
43
+ if (!Number.isInteger(m.lineHeight) || m.lineHeight < 1 || m.lineHeight > 255)
44
+ fail(`line height ${m.lineHeight} is outside 1..255`);
45
+ const glyphs = Array.from({ length: Math.max(128, m.last + 1) }, () => ({ width: 0, height: 0, pixels: new Uint8Array(0) }));
46
+ const seen = new Set();
47
+ for (const g of m.glyphs) {
48
+ const [x, y, w, h] = g.rect;
49
+ if (!Number.isInteger(g.code) || g.code < 0 || g.code > 255)
50
+ fail(`character ${g.code} is outside 0..255`);
51
+ if (seen.has(g.code))
52
+ fail(`character ${g.code} is there twice`);
53
+ seen.add(g.code);
54
+ if (x < 0 || y < 0 || x + w > img.width || y + h > img.height)
55
+ fail(`character ${g.code}: its rectangle ${g.rect.join(",")} is outside the ${img.width}x${img.height} atlas`);
56
+ if (g.top < 0 || g.top + h > 255)
57
+ fail(`character ${g.code}: top ${g.top} with height ${h}`);
58
+ const height = g.top + h;
59
+ const pixels = new Uint8Array(w * height);
60
+ for (let gy = 0; gy < h; gy++) {
61
+ for (let gx = 0; gx < w; gx++) {
62
+ const a = img.data[((y + gy) * img.width + x + gx) * 4 + 3];
63
+ if (a !== 0 && a !== 255)
64
+ fail(`character ${g.code} (${x + gx},${y + gy}): alpha ${a}; the ink is opaque or clear`);
65
+ if (a)
66
+ pixels[(g.top + gy) * w + gx] = 1;
67
+ }
68
+ }
69
+ glyphs[g.code] = { width: w, height, pixels, ...(g.bearingX ? { bearingX: g.bearingX } : {}), ...(g.advance !== w ? { advance: g.advance } : {}) };
70
+ }
71
+ for (let c = m.first; c <= m.last; c++)
72
+ if (!seen.has(c))
73
+ fail(`character ${c} is in ${m.first}..${m.last} but has no glyph`);
74
+ return { height: m.lineHeight, glyphs };
75
+ }
package/tools/art/font.ts CHANGED
@@ -54,3 +54,44 @@ export function fontFromSheet(img: Rgba, sheet: FontSheet): Font {
54
54
  }
55
55
  return { height: lineHeight, glyphs };
56
56
  }
57
+
58
+ /**
59
+ * A font from a bitmap font's own metrics (sidecar JSON next to an atlas, as exported from
60
+ * BDF): each glyph's rectangle in the atlas, where its top sits below the line's top, its
61
+ * bearing from the pen and its advance. Nothing is trimmed, moved or respaced: the glyph is
62
+ * the rectangle's ink, with blank rows above it down to its top.
63
+ */
64
+ export interface FontMetrics {
65
+ lineHeight: number;
66
+ first: number;
67
+ last: number;
68
+ atlas: string;
69
+ glyphs: { code: number; rect: [number, number, number, number]; bearingX: number; top: number; advance: number }[];
70
+ }
71
+
72
+ export function fontFromMetrics(img: Rgba, m: FontMetrics): Font {
73
+ const fail = (message: string): never => { throw new FontSheetError(message); };
74
+ if (!Number.isInteger(m.lineHeight) || m.lineHeight < 1 || m.lineHeight > 255) fail(`line height ${m.lineHeight} is outside 1..255`);
75
+ const glyphs: (Glyph | undefined)[] = Array.from({ length: Math.max(128, m.last + 1) }, () => ({ width: 0, height: 0, pixels: new Uint8Array(0) }));
76
+ const seen = new Set<number>();
77
+ for (const g of m.glyphs) {
78
+ const [x, y, w, h] = g.rect;
79
+ if (!Number.isInteger(g.code) || g.code < 0 || g.code > 255) fail(`character ${g.code} is outside 0..255`);
80
+ if (seen.has(g.code)) fail(`character ${g.code} is there twice`);
81
+ seen.add(g.code);
82
+ if (x < 0 || y < 0 || x + w > img.width || y + h > img.height) fail(`character ${g.code}: its rectangle ${g.rect.join(",")} is outside the ${img.width}x${img.height} atlas`);
83
+ if (g.top < 0 || g.top + h > 255) fail(`character ${g.code}: top ${g.top} with height ${h}`);
84
+ const height = g.top + h;
85
+ const pixels = new Uint8Array(w * height);
86
+ for (let gy = 0; gy < h; gy++) {
87
+ for (let gx = 0; gx < w; gx++) {
88
+ const a = img.data[((y + gy) * img.width + x + gx) * 4 + 3]!;
89
+ if (a !== 0 && a !== 255) fail(`character ${g.code} (${x + gx},${y + gy}): alpha ${a}; the ink is opaque or clear`);
90
+ if (a) pixels[(g.top + gy) * w + gx] = 1;
91
+ }
92
+ }
93
+ glyphs[g.code] = { width: w, height, pixels, ...(g.bearingX ? { bearingX: g.bearingX } : {}), ...(g.advance !== w ? { advance: g.advance } : {}) };
94
+ }
95
+ for (let c = m.first; c <= m.last; c++) if (!seen.has(c)) fail(`character ${c} is in ${m.first}..${m.last} but has no glyph`);
96
+ return { height: m.lineHeight, glyphs };
97
+ }
@@ -20,7 +20,9 @@ import { type SpokenLine } from "../../packages/content/src/index.ts";
20
20
  * voices/lines.json, the script, up to date
21
21
  * games/<name>/resources.ts optional: `export default () => ResourceData[]` for art,
22
22
  * music and anything else made by code
23
- * games/<name>/game.json optional: { "library": false } to build without lib/
23
+ * games/<name>/game.json optional: { "library": false } to build without lib/;
24
+ * "fonts": { "bold": 3, "italic": 4, "boldItalic": 5 }
25
+ * for the rooms' [b] and [i] markup
24
26
  *
25
27
  * plus the class library (lib/: its scripts and resources) and font 0, palette 999 and cursor
26
28
  * view 999 (tools/game/defaults.ts), unless the game makes its own. Selectors are numbered as the scripts use them, starting with the nine object
@@ -20,6 +20,17 @@ import { ITEMS_SCRIPT, libraryTarget } from "./target.js";
20
20
  const HEADER = OBJECT_HEADER;
21
21
  export class GameBuildError extends Error {
22
22
  }
23
+ /** game.json's fonts for the rooms' markup: bold, italic and boldItalic font numbers. */
24
+ function gameFonts(value) {
25
+ if (value === undefined)
26
+ return undefined;
27
+ const ok = (n) => Number.isInteger(n) && n >= 0 && n <= 65535;
28
+ const f = value;
29
+ if (typeof value !== "object" || value === null || !ok(f.bold) || !ok(f.italic) || !ok(f.boldItalic)) {
30
+ throw new GameBuildError(`game.json: "fonts" is { "bold": n, "italic": n, "boldItalic": n }, font numbers`);
31
+ }
32
+ return { bold: f.bold, italic: f.italic, boldItalic: f.boldItalic };
33
+ }
23
34
  /** sci-ts's class library: scripts every game gets, and the resources they use. */
24
35
  export const LIBRARY_DIR = resolve(import.meta.dirname, "../../lib");
25
36
  export async function buildGame(dir, options = {}) {
@@ -27,6 +38,7 @@ export async function buildGame(dir, options = {}) {
27
38
  if (!existsSync(scriptsDir))
28
39
  throw new GameBuildError(`${dir}: no scripts/ folder`);
29
40
  const config = existsSync(join(dir, "game.json")) ? JSON.parse(readFileSync(join(dir, "game.json"), "utf8")) : {};
41
+ const fonts = gameFonts(config.fonts);
30
42
  const library = options.library ?? config.library ?? true;
31
43
  const load = (from, re) => readdirSync(from).sort().filter((f) => re.test(f)).map((f) => ({ file: join(from, f), text: readFileSync(join(from, f), "utf8") }));
32
44
  const sources = load(scriptsDir, /^\d+\.sca$/);
@@ -119,7 +131,7 @@ export async function buildGame(dir, options = {}) {
119
131
  const roomsDir = join(dir, "rooms");
120
132
  if (!existsSync(roomsDir))
121
133
  return [];
122
- const target = libraryTarget(globals, items);
134
+ const target = { ...libraryTarget(globals, items), ...(fonts ? { fonts } : {}) };
123
135
  const flags = modFlags(dir, { content: target });
124
136
  const out = [];
125
137
  for (const { file, text } of load(roomsDir, /^\d+\.room\.yaml$/)) {
@@ -34,7 +34,9 @@ import { ITEMS_SCRIPT, libraryTarget } from "./target.ts";
34
34
  * voices/lines.json, the script, up to date
35
35
  * games/<name>/resources.ts optional: `export default () => ResourceData[]` for art,
36
36
  * music and anything else made by code
37
- * games/<name>/game.json optional: { "library": false } to build without lib/
37
+ * games/<name>/game.json optional: { "library": false } to build without lib/;
38
+ * "fonts": { "bold": 3, "italic": 4, "boldItalic": 5 }
39
+ * for the rooms' [b] and [i] markup
38
40
  *
39
41
  * plus the class library (lib/: its scripts and resources) and font 0, palette 999 and cursor
40
42
  * view 999 (tools/game/defaults.ts), unless the game makes its own. Selectors are numbered as the scripts use them, starting with the nine object
@@ -62,13 +64,25 @@ const HEADER = OBJECT_HEADER;
62
64
 
63
65
  export class GameBuildError extends Error {}
64
66
 
67
+ /** game.json's fonts for the rooms' markup: bold, italic and boldItalic font numbers. */
68
+ function gameFonts(value: unknown): { bold: number; italic: number; boldItalic: number } | undefined {
69
+ if (value === undefined) return undefined;
70
+ const ok = (n: unknown) => Number.isInteger(n) && (n as number) >= 0 && (n as number) <= 65535;
71
+ const f = value as Record<string, unknown>;
72
+ if (typeof value !== "object" || value === null || !ok(f.bold) || !ok(f.italic) || !ok(f.boldItalic)) {
73
+ throw new GameBuildError(`game.json: "fonts" is { "bold": n, "italic": n, "boldItalic": n }, font numbers`);
74
+ }
75
+ return { bold: f.bold as number, italic: f.italic as number, boldItalic: f.boldItalic as number };
76
+ }
77
+
65
78
  /** sci-ts's class library: scripts every game gets, and the resources they use. */
66
79
  export const LIBRARY_DIR = resolve(import.meta.dirname, "../../lib");
67
80
 
68
81
  export async function buildGame(dir: string, options: { library?: boolean } = {}): Promise<BuiltGame> {
69
82
  const scriptsDir = join(dir, "scripts");
70
83
  if (!existsSync(scriptsDir)) throw new GameBuildError(`${dir}: no scripts/ folder`);
71
- const config = existsSync(join(dir, "game.json")) ? (JSON.parse(readFileSync(join(dir, "game.json"), "utf8")) as { library?: boolean }) : {};
84
+ const config = existsSync(join(dir, "game.json")) ? (JSON.parse(readFileSync(join(dir, "game.json"), "utf8")) as { library?: boolean; fonts?: unknown }) : {};
85
+ const fonts = gameFonts(config.fonts);
72
86
  const library = options.library ?? config.library ?? true;
73
87
  const load = (from: string, re: RegExp) =>
74
88
  readdirSync(from).sort().filter((f) => re.test(f)).map((f) => ({ file: join(from, f), text: readFileSync(join(from, f), "utf8") }));
@@ -158,7 +172,7 @@ export async function buildGame(dir: string, options: { library?: boolean } = {}
158
172
  const compileRooms = (): { file: string; text: string }[] => {
159
173
  const roomsDir = join(dir, "rooms");
160
174
  if (!existsSync(roomsDir)) return [];
161
- const target = libraryTarget(globals, items);
175
+ const target = { ...libraryTarget(globals, items), ...(fonts ? { fonts } : {}) };
162
176
  const flags = modFlags(dir, { content: target });
163
177
  const out: { file: string; text: string }[] = [];
164
178
  for (const { file, text } of load(roomsDir, /^\d+\.room\.yaml$/)) {
@@ -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
+ }