sci2-ts 0.14.0 → 0.15.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.
@@ -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$/)) {