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.
- package/CHANGELOG.md +24 -5
- package/apps/viewer/src/session.js +12 -2
- package/apps/viewer/src/session.ts +12 -2
- package/lib/992.sc +173 -0
- package/lib/993.sc +135 -0
- package/lib/994.sc +13 -0
- package/lib/995.sc +64 -5
- package/lib/996.sc +88 -26
- package/lib/997.sc +26 -0
- package/lib/998.sc +43 -5
- package/lib/999.sc +4 -1
- package/lib/resources.js +33 -3
- package/lib/resources.ts +34 -3
- package/lib/system.sh +38 -1
- package/package.json +1 -1
- package/packages/content/src/room.js +13 -1
- package/packages/content/src/room.ts +11 -1
- package/packages/content/src/target.d.ts +5 -0
- package/packages/content/src/target.ts +5 -0
- package/packages/sci/src/vm/kernels/index.d.ts +1 -1
- package/packages/sci/src/vm/kernels/index.js +1 -1
- package/packages/sci/src/vm/kernels/index.ts +1 -1
- package/packages/sci/src/vm/kernels/saves.d.ts +8 -0
- package/packages/sci/src/vm/kernels/saves.js +24 -3
- package/packages/sci/src/vm/kernels/saves.ts +30 -3
- package/packages/sci/src/vm/kernels/text.js +27 -2
- package/packages/sci/src/vm/kernels/text.ts +23 -2
- package/tools/art/build.d.ts +10 -0
- package/tools/art/build.js +32 -2
- package/tools/art/build.ts +28 -2
- package/tools/art/font.d.ts +24 -0
- package/tools/art/font.js +40 -0
- package/tools/art/font.ts +56 -0
- package/tools/game/build.d.ts +4 -0
- package/tools/game/build.js +27 -1
- package/tools/game/build.ts +24 -1
- package/tools/game/kit.d.ts +1 -0
- package/tools/game/kit.js +1 -0
- package/tools/game/kit.ts +1 -0
- package/tools/game/target.d.ts +1 -1
- package/tools/game/target.js +5 -3
- 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
|
-
|
|
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
|
-
|
|
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: (
|
|
334
|
-
|
|
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: (
|
|
325
|
-
|
|
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
|
};
|
package/tools/art/build.d.ts
CHANGED
|
@@ -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
|
}
|
package/tools/art/build.js
CHANGED
|
@@ -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
|
}
|
package/tools/art/build.ts
CHANGED
|
@@ -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
|
+
}
|
package/tools/game/build.d.ts
CHANGED
|
@@ -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>;
|
package/tools/game/build.js
CHANGED
|
@@ -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
|
+
}
|
package/tools/game/build.ts
CHANGED
|
@@ -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
|
+
}
|
package/tools/game/kit.d.ts
CHANGED
|
@@ -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
package/tools/game/target.d.ts
CHANGED
|
@@ -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;
|
package/tools/game/target.js
CHANGED
|
@@ -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: [
|
|
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
|
-
|
|
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 },
|
package/tools/game/target.ts
CHANGED
|
@@ -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: [
|
|
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
|
-
|
|
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 },
|