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.
- package/CHANGELOG.md +12 -0
- package/apps/viewer/src/files.d.ts +5 -0
- package/apps/viewer/src/files.js +19 -5
- package/apps/viewer/src/files.ts +20 -5
- package/lib/991.sc +44 -30
- package/lib/993.sc +40 -6
- package/lib/996.sc +8 -3
- package/package.json +1 -1
- package/packages/content/src/room.js +46 -3
- package/packages/content/src/room.ts +41 -3
- package/packages/content/src/target.d.ts +9 -0
- package/packages/content/src/target.ts +5 -0
- package/packages/sci/src/index.d.ts +1 -0
- package/packages/sci/src/index.js +1 -0
- package/packages/sci/src/index.ts +1 -0
- package/packages/sci/src/text/font.d.ts +19 -2
- package/packages/sci/src/text/font.js +62 -14
- package/packages/sci/src/text/font.ts +64 -15
- package/packages/sci/src/text/styled.d.ts +40 -0
- package/packages/sci/src/text/styled.js +92 -0
- package/packages/sci/src/text/styled.ts +110 -0
- package/packages/sci/src/vm/kernels/text.js +40 -29
- package/packages/sci/src/vm/kernels/text.ts +41 -28
- package/tools/art/build.d.ts +9 -3
- package/tools/art/build.js +33 -2
- package/tools/art/build.ts +27 -4
- package/tools/art/font.d.ts +20 -0
- package/tools/art/font.js +35 -0
- package/tools/art/font.ts +41 -0
- package/tools/game/build.d.ts +3 -1
- package/tools/game/build.js +13 -1
- package/tools/game/build.ts +17 -3
- package/tools/site/build.d.ts +2 -0
- package/tools/site/build.js +10 -1
- package/tools/site/build.ts +10 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ResourceType } from "../../resource/types.ts";
|
|
2
|
-
import { parseFont,
|
|
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
|
-
/**
|
|
110
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
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 =
|
|
125
|
-
if (g.pixels[gy * g.width + gx] && px >= 0 && px < bmp.width) bmp.pixels[y * bmp.width + px] =
|
|
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
|
-
|
|
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 =
|
|
197
|
-
h = font.height;
|
|
211
|
+
({ width: w, height: h } = one);
|
|
198
212
|
} else {
|
|
199
|
-
const
|
|
200
|
-
w = Math.min(limit,
|
|
201
|
-
h =
|
|
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 =
|
|
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]) =>
|
|
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
|
|
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)!,
|
|
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 };
|
package/tools/art/build.d.ts
CHANGED
|
@@ -24,8 +24,11 @@ export interface ArtManifest {
|
|
|
24
24
|
mirror: boolean;
|
|
25
25
|
})[];
|
|
26
26
|
}[];
|
|
27
|
-
/**
|
|
28
|
-
|
|
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
|
}
|
package/tools/art/build.js
CHANGED
|
@@ -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 {
|
package/tools/art/build.ts
CHANGED
|
@@ -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
|
-
/**
|
|
17
|
-
|
|
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)); }
|
package/tools/art/font.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/tools/game/build.d.ts
CHANGED
|
@@ -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
|
package/tools/game/build.js
CHANGED
|
@@ -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$/)) {
|
package/tools/game/build.ts
CHANGED
|
@@ -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$/)) {
|
package/tools/site/build.d.ts
CHANGED
|
@@ -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;
|
package/tools/site/build.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/tools/site/build.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|