sci2-ts 0.8.2 → 0.10.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 +12 -0
- package/README.md +1 -1
- package/apps/viewer/src/files.js +3 -1
- package/apps/viewer/src/files.ts +3 -1
- package/lib/992.sc +17 -3
- package/lib/996.sc +41 -20
- package/lib/999.sc +2 -1
- package/lib/system.sh +9 -0
- package/package.json +1 -1
- package/packages/content/room.schema.json +24 -1
- package/packages/content/src/room.d.ts +45 -2
- package/packages/content/src/room.js +69 -7
- package/packages/content/src/room.ts +102 -9
- package/packages/content/src/yarn.d.ts +13 -0
- package/packages/content/src/yarn.js +52 -2
- package/packages/content/src/yarn.ts +51 -2
- package/packages/sci/src/audio/sol.d.ts +2 -0
- package/packages/sci/src/audio/sol.js +2 -1
- package/packages/sci/src/audio/sol.ts +2 -1
- package/packages/sci/src/audio/write.d.ts +37 -2
- package/packages/sci/src/audio/write.js +96 -7
- package/packages/sci/src/audio/write.ts +96 -7
- package/packages/sci/src/vm/kernels/audio.js +1 -1
- package/packages/sci/src/vm/kernels/audio.ts +1 -1
- package/tools/cli.js +36 -1
- package/tools/cli.ts +30 -1
- package/tools/game/audio.d.ts +14 -0
- package/tools/game/audio.js +62 -0
- package/tools/game/audio.ts +58 -0
- package/tools/game/build.d.ts +7 -1
- package/tools/game/build.js +59 -2
- package/tools/game/build.ts +62 -4
- package/tools/game/lines.d.ts +8 -0
- package/tools/game/lines.js +53 -0
- package/tools/game/lines.ts +44 -0
- package/tools/game/voices.d.ts +37 -0
- package/tools/game/voices.js +44 -0
- package/tools/game/voices.ts +67 -0
|
@@ -7,7 +7,8 @@ import { parseYarn, type YarnChoice, type YarnCommand, type YarnExpr, type YarnI
|
|
|
7
7
|
* (.sca) and message text (.msg), the formats tools/mod-build.ts already builds from.
|
|
8
8
|
*
|
|
9
9
|
* room: 701
|
|
10
|
-
* hero: { at: [36, 152], enterTo: [110, 160]
|
|
10
|
+
* hero: { at: [36, 152], enterTo: [110, 160] }
|
|
11
|
+
* perspective: { horizon: 72, fullSize: 176 }
|
|
11
12
|
* walkable: [[28, 152], [60, 135], ...]
|
|
12
13
|
* features:
|
|
13
14
|
* pods: { rect: [236, 14, 310, 94] }
|
|
@@ -31,9 +32,17 @@ export interface RoomSpec {
|
|
|
31
32
|
at?: Point;
|
|
32
33
|
/** Walk here on arrival (hands off until there). */
|
|
33
34
|
enterTo?: Point;
|
|
34
|
-
/** Perspective: `front`% at y `frontY`, `back`% at y `backY
|
|
35
|
+
/** Perspective for the hero alone: `front`% at y `frontY`, `back`% at y `backY` (perspective: is simpler). */
|
|
35
36
|
scale?: { front: number; back: number; frontY: number; backY: number };
|
|
36
37
|
};
|
|
38
|
+
/**
|
|
39
|
+
* The picture's camera, for sizing people by where they stand: the horizon (the y the
|
|
40
|
+
* floor's lines meet at), and the y where a figure's feet are when it's drawn at full
|
|
41
|
+
* size. A figure's height is in proportion to how far below the horizon it stands, so it
|
|
42
|
+
* shrinks towards the back and grows past fullSize at the front. The hero and props that
|
|
43
|
+
* move are sized as they go; a still prop only with scale: true.
|
|
44
|
+
*/
|
|
45
|
+
perspective?: { horizon: number; fullSize: number };
|
|
37
46
|
/** The floor: a polygon the hero stays inside. */
|
|
38
47
|
walkable?: Point[];
|
|
39
48
|
/** Polygons the hero walks around. */
|
|
@@ -87,6 +96,11 @@ interface PropSpec {
|
|
|
87
96
|
sightAngle?: number;
|
|
88
97
|
/** A Script class of the game's: an instance of it runs on the prop (ambient behaviour). */
|
|
89
98
|
script?: string;
|
|
99
|
+
/**
|
|
100
|
+
* Sized by the room's perspective: by default a prop that moves is and a still one
|
|
101
|
+
* (drawn to fit its place, like someone in a chair) isn't.
|
|
102
|
+
*/
|
|
103
|
+
scale?: boolean;
|
|
90
104
|
}
|
|
91
105
|
|
|
92
106
|
export interface CompiledRoom {
|
|
@@ -99,6 +113,30 @@ export interface CompiledRoom {
|
|
|
99
113
|
roomTalkers: boolean;
|
|
100
114
|
/** Yarn variables ($name) used: each is a flag, numbered by `options.flag`. */
|
|
101
115
|
flags: string[];
|
|
116
|
+
/** The spoken lines (not menu labels), with their message keys today. */
|
|
117
|
+
lines: SpokenLine[];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A line someone says. Its message key (noun, verb, cond, seq) is positional and changes
|
|
122
|
+
* when lines or things are added before it; its id (#line:) doesn't, so recordings use that.
|
|
123
|
+
*/
|
|
124
|
+
export interface SpokenLine {
|
|
125
|
+
id?: string;
|
|
126
|
+
noun: number;
|
|
127
|
+
verb: number;
|
|
128
|
+
cond: number;
|
|
129
|
+
seq: number;
|
|
130
|
+
talker: number;
|
|
131
|
+
/** As written in the Yarn (Narrator when it has none). */
|
|
132
|
+
speaker: string;
|
|
133
|
+
text: string;
|
|
134
|
+
/** The Yarn node it's in (and for a topic's answer, the topic). */
|
|
135
|
+
node: string;
|
|
136
|
+
/** What's said just before it in the same node, if anything: context for whoever records it. */
|
|
137
|
+
before?: { speaker: string; text: string };
|
|
138
|
+
/** file:line in the Yarn. */
|
|
139
|
+
where: string;
|
|
102
140
|
}
|
|
103
141
|
|
|
104
142
|
export interface CompileOptions {
|
|
@@ -157,7 +195,7 @@ class SpecChecker {
|
|
|
157
195
|
check(): void {
|
|
158
196
|
const s = this.spec as unknown as Record<string, unknown>;
|
|
159
197
|
if (!s || typeof s !== "object") this.fail([], "expected a mapping (room: ..., features: ...)");
|
|
160
|
-
this.known(s, [], ["room", "picture", "music", "hero", "walkable", "obstacles", "features", "exits", "props", "characters", "properties"]);
|
|
198
|
+
this.known(s, [], ["room", "picture", "music", "hero", "perspective", "walkable", "obstacles", "features", "exits", "props", "characters", "properties"]);
|
|
161
199
|
this.int(s.room, ["room"], 0, 65535);
|
|
162
200
|
if (s.picture !== undefined) this.int(s.picture, ["picture"], 0, 65535);
|
|
163
201
|
if (s.music !== undefined) this.int(s.music, ["music"], 0, 65535);
|
|
@@ -172,6 +210,13 @@ class SpecChecker {
|
|
|
172
210
|
for (const k of ["front", "back", "frontY", "backY"]) this.int(sc[k], ["hero", "scale", k], 0, 1000);
|
|
173
211
|
}
|
|
174
212
|
}
|
|
213
|
+
if (s.perspective !== undefined) {
|
|
214
|
+
const pv = this.map(s.perspective, ["perspective"]);
|
|
215
|
+
this.known(pv, ["perspective"], ["horizon", "fullSize"]);
|
|
216
|
+
this.int(pv.horizon, ["perspective", "horizon"], -1000, 199);
|
|
217
|
+
this.int(pv.fullSize, ["perspective", "fullSize"], 0, 1000);
|
|
218
|
+
if ((pv.fullSize as number) <= (pv.horizon as number)) this.fail(["perspective", "fullSize"], "must be below the horizon (a bigger y)");
|
|
219
|
+
}
|
|
175
220
|
if (s.walkable !== undefined) this.polygon(s.walkable, ["walkable"]);
|
|
176
221
|
if (s.obstacles !== undefined) {
|
|
177
222
|
if (!Array.isArray(s.obstacles)) this.fail(["obstacles"], "expected a list of polygons");
|
|
@@ -188,7 +233,9 @@ class SpecChecker {
|
|
|
188
233
|
names.add(name);
|
|
189
234
|
const t = this.map(value, path);
|
|
190
235
|
if (group === "props") {
|
|
191
|
-
this.known(t, path, ["view", "at", "loop", "cel", "cycle", "sightAngle", "moves", "script"]);
|
|
236
|
+
this.known(t, path, ["view", "at", "loop", "cel", "cycle", "sightAngle", "moves", "script", "scale"]);
|
|
237
|
+
if (t.scale !== undefined && typeof t.scale !== "boolean") this.fail([...path, "scale"], "expected true or false");
|
|
238
|
+
if (t.scale === true && s.perspective === undefined) this.fail([...path, "scale"], "the room has no perspective: to size it by");
|
|
192
239
|
if (t.script !== undefined && (typeof t.script !== "string" || !/^[A-Z][A-Za-z0-9]*$/.test(t.script))) this.fail([...path, "script"], "expected a class name, e.g. Scurry");
|
|
193
240
|
if (t.moves !== undefined && typeof t.moves !== "boolean") this.fail([...path, "moves"], "expected true or false");
|
|
194
241
|
this.int(t.view, [...path, "view"], 0, 65535);
|
|
@@ -265,6 +312,7 @@ type Val = number | { obj: string } | { cls: string } | "self" | { code: string[
|
|
|
265
312
|
/** Who receives a send. */
|
|
266
313
|
type To = { global: number } | { obj: string } | { cls: string } | "self" | { superOf: string };
|
|
267
314
|
|
|
315
|
+
const SCREEN_BOTTOM = 200;
|
|
268
316
|
const num = (n: number) => (n < 0 ? `-$${(-n).toString(16)}` : String(n));
|
|
269
317
|
|
|
270
318
|
function pushVal(v: Val): string[] {
|
|
@@ -300,6 +348,7 @@ interface Message {
|
|
|
300
348
|
cond: number;
|
|
301
349
|
seq: number;
|
|
302
350
|
talker: number;
|
|
351
|
+
speaker: string;
|
|
303
352
|
text: string;
|
|
304
353
|
label: string;
|
|
305
354
|
}
|
|
@@ -347,6 +396,7 @@ class RoomCompiler {
|
|
|
347
396
|
readonly name: string;
|
|
348
397
|
readonly nouns = new Map<string, number>();
|
|
349
398
|
readonly messages: Message[] = [];
|
|
399
|
+
readonly lines: SpokenLine[] = [];
|
|
350
400
|
readonly instances: string[] = [];
|
|
351
401
|
readonly code: string[] = [];
|
|
352
402
|
readonly strings: string[] = [];
|
|
@@ -425,7 +475,7 @@ class RoomCompiler {
|
|
|
425
475
|
|
|
426
476
|
return {
|
|
427
477
|
number: spec.room, sca: this.assembly(), msg: this.messageText(), roomTalkers: characters.length > 0,
|
|
428
|
-
flags: [...this.flagsUsed].sort(),
|
|
478
|
+
flags: [...this.flagsUsed].sort(), lines: this.lines,
|
|
429
479
|
};
|
|
430
480
|
}
|
|
431
481
|
|
|
@@ -492,7 +542,7 @@ class RoomCompiler {
|
|
|
492
542
|
if (!c.body.length) fail(`choice "${c.text}" needs at least one line under it`, c.line);
|
|
493
543
|
const topic = k + 1;
|
|
494
544
|
const label = `${node.title}: ${c.text}`;
|
|
495
|
-
this.message(conv.rootNoun, tv, topic, 1, { kind: "line", speaker: "Hero", text: c.text, line: c.line }, label);
|
|
545
|
+
this.message(conv.rootNoun, tv, topic, 1, { kind: "line", speaker: "Hero", text: c.text, line: c.line }, label, false);
|
|
496
546
|
if (c.when) conv.conditional.push({ topic, when: c.when, where: `${this.yarnFile}:${c.line}` });
|
|
497
547
|
if (c.body.every((x) => x.kind === "line")) {
|
|
498
548
|
c.body.forEach((l, j) => this.message(conv.sayNoun, tv, topic, j + 1, l as YarnLine, label));
|
|
@@ -794,9 +844,19 @@ class RoomCompiler {
|
|
|
794
844
|
|
|
795
845
|
// --- Objects ---
|
|
796
846
|
|
|
797
|
-
|
|
847
|
+
/** A message; `spoken` is false for a topic menu's labels, which nobody says. */
|
|
848
|
+
private message(noun: number, verb: number, cond: number, seq: number, l: YarnLine, label: string, spoken = true): void {
|
|
798
849
|
const where = `${this.yarnFile}:${l.line}`;
|
|
799
|
-
|
|
850
|
+
const prev = this.messages.at(-1);
|
|
851
|
+
const m: Message = { noun, verb, cond, seq, talker: this.talkerOf(l.speaker, where), speaker: l.speaker ?? "Narrator", text: plainText(l.text, where), label };
|
|
852
|
+
this.messages.push(m);
|
|
853
|
+
if (!spoken) return;
|
|
854
|
+
const twice = l.id !== undefined && this.lines.find((x) => x.id === l.id);
|
|
855
|
+
if (twice) throw new ContentError(`${where}: #line:${l.id} is already the id of ${twice.where}`);
|
|
856
|
+
this.lines.push({
|
|
857
|
+
...(l.id === undefined ? {} : { id: l.id }), noun, verb, cond, seq, talker: m.talker, speaker: m.speaker, text: m.text, node: label,
|
|
858
|
+
...(prev?.label === label ? { before: { speaker: prev.speaker, text: prev.text } } : {}), where,
|
|
859
|
+
});
|
|
800
860
|
}
|
|
801
861
|
|
|
802
862
|
private talkerOf(speaker: string | undefined, where: string): number {
|
|
@@ -836,13 +896,18 @@ class RoomCompiler {
|
|
|
836
896
|
const egoCalls: [string, Val[]][] = [["init", []]];
|
|
837
897
|
if (hero?.at) egoCalls.push(["x", [hero.at[0]]], ["y", [hero.at[1]]]);
|
|
838
898
|
egoCalls.push(["normalize", []]);
|
|
899
|
+
// Each room sizes the hero its own way, or not at all.
|
|
900
|
+
const perspective = this.perspective();
|
|
839
901
|
if (hero?.scale) egoCalls.push(["setScaler", [{ cls: "Scaler" }, hero.scale.front, hero.scale.back, hero.scale.frontY, hero.scale.backY]]);
|
|
902
|
+
else if (perspective) egoCalls.push(["setScaler", [{ cls: "Scaler" }, ...perspective]]);
|
|
903
|
+
else egoCalls.push(["setScaler", [0]]);
|
|
840
904
|
c.push(...send({ global: target.globals.ego }, egoCalls));
|
|
841
905
|
if (spec.music !== undefined) c.push(...this.playMusic(spec.music));
|
|
842
906
|
for (const name of [...Object.keys(spec.features ?? {}), ...Object.keys(spec.exits ?? {})]) c.push(...send({ obj: name }, [["init", []]]));
|
|
843
907
|
for (const [name, p] of Object.entries(spec.props ?? {})) {
|
|
844
908
|
c.push(...send({ obj: name }, [
|
|
845
909
|
["init", []],
|
|
910
|
+
...(perspective && p.moves && p.scale !== false ? [["setScaler", [{ cls: "Scaler" }, ...perspective]] as [string, Val[]]] : []),
|
|
846
911
|
...(p.cycle === "forward" ? [["setCycle", [{ cls: this.target.forwardCycle }]] as [string, Val[]]] : []),
|
|
847
912
|
// A new instance of the game's Script class, running on the prop.
|
|
848
913
|
...(p.script ? [["setScript", [{ code: ["pushi #new", "push0", `class ${p.script}`, "send 4"] }]] as [string, Val[]]] : []),
|
|
@@ -974,11 +1039,39 @@ class RoomCompiler {
|
|
|
974
1039
|
if (cases.length) this.doVerb(name, "Feature", cases);
|
|
975
1040
|
}
|
|
976
1041
|
|
|
1042
|
+
/**
|
|
1043
|
+
* The room's perspective as Scaler arguments (front, back, frontY, backY): sizes at the
|
|
1044
|
+
* nearest and farthest places anyone stands (the floor, and where the hero and props that
|
|
1045
|
+
* move start). Size is a straight line in y, so the Scaler's in-between is exact; keeping
|
|
1046
|
+
* to that range keeps its 16-bit arithmetic from overflowing.
|
|
1047
|
+
*/
|
|
1048
|
+
private perspective(): [number, number, number, number] | undefined {
|
|
1049
|
+
const pv = this.spec.perspective;
|
|
1050
|
+
if (!pv) return undefined;
|
|
1051
|
+
const ys = [
|
|
1052
|
+
...(this.spec.walkable ?? []).map((p) => p[1]),
|
|
1053
|
+
...Object.values(this.spec.props ?? {}).filter((p) => p.moves).map((p) => p.at[1]),
|
|
1054
|
+
...[this.spec.hero?.at, this.spec.hero?.enterTo].flatMap((p) => (p ? [p[1]] : [])),
|
|
1055
|
+
].filter((y) => y > pv.horizon);
|
|
1056
|
+
const backY = ys.length ? Math.min(...ys) : pv.horizon + 1, frontY = ys.length ? Math.max(...ys) : SCREEN_BOTTOM - 1;
|
|
1057
|
+
const size = (y: number) => Math.round(((y - pv.horizon) / (pv.fullSize - pv.horizon)) * 100);
|
|
1058
|
+
const [front, back] = [size(frontY), size(backY)];
|
|
1059
|
+
// Scaler works out (front - back) * (y - backY) on the way.
|
|
1060
|
+
if ((front - back) * (frontY - backY) > 32767) throw new ContentError(`room ${this.spec.room}: perspective: sizes from ${back}% to ${front}% over ${frontY - backY} lines are too much for the Scaler`);
|
|
1061
|
+
return [front, back, Math.max(frontY, backY + 1), backY];
|
|
1062
|
+
}
|
|
1063
|
+
|
|
977
1064
|
private prop(name: string, p: PropSpec): void {
|
|
978
1065
|
const cases = this.scriptedVerbs.get(name) ?? [];
|
|
1066
|
+
// A still prop is sized once, for where it is.
|
|
1067
|
+
const pv = this.spec.perspective;
|
|
1068
|
+
const fixed = pv && p.scale === true && !p.moves ? Math.round(((p.at[1] - pv.horizon) / (pv.fullSize - pv.horizon)) * 128) : undefined;
|
|
979
1069
|
this.instance(
|
|
980
1070
|
name, p.moves ? "Actor" : "Prop",
|
|
981
|
-
{
|
|
1071
|
+
{
|
|
1072
|
+
noun: this.nouns.get(name)!, modNum: -1, sightAngle: p.sightAngle ?? 180, x: p.at[0], y: p.at[1], view: p.view, loop: p.loop ?? 0, cel: p.cel ?? 0,
|
|
1073
|
+
...(fixed === undefined ? {} : { scaleSignal: 1, scaleX: fixed, scaleY: fixed }),
|
|
1074
|
+
},
|
|
982
1075
|
cases.length ? ["doVerb"] : [],
|
|
983
1076
|
);
|
|
984
1077
|
if (cases.length) this.doVerb(name, p.moves ? "Actor" : "Prop", cases);
|
|
@@ -40,6 +40,8 @@ export interface YarnLine {
|
|
|
40
40
|
kind: "line";
|
|
41
41
|
speaker?: string;
|
|
42
42
|
text: string;
|
|
43
|
+
/** Its #line: tag, which stays with it when lines around it change (recordings use it). */
|
|
44
|
+
id?: string;
|
|
43
45
|
line: number;
|
|
44
46
|
}
|
|
45
47
|
export interface YarnChoice {
|
|
@@ -88,3 +90,14 @@ export declare class YarnError extends Error {
|
|
|
88
90
|
export declare function parseYarn(source: string, file?: string): YarnNode[];
|
|
89
91
|
/** `$a and not ($b or $c == false)`: Yarn's boolean operators, both spellings. */
|
|
90
92
|
export declare function parseExpr(source: string, fail: (why: string) => never): YarnExpr;
|
|
93
|
+
/** Every line in some items, including those under choices and in <<if>> branches. */
|
|
94
|
+
export declare function yarnLines(items: YarnItem[]): YarnLine[];
|
|
95
|
+
/**
|
|
96
|
+
* Gives every line without a #line: tag one, `<prefix>-001` and on, skipping ids already
|
|
97
|
+
* `taken` (pass the game's, so they're unique across rooms). Lines that have an id keep it,
|
|
98
|
+
* and nothing else in the file changes.
|
|
99
|
+
*/
|
|
100
|
+
export declare function tagLines(source: string, prefix: string, taken: Set<string>, file?: string): {
|
|
101
|
+
source: string;
|
|
102
|
+
added: string[];
|
|
103
|
+
};
|
|
@@ -8,7 +8,18 @@ export class YarnError extends Error {
|
|
|
8
8
|
}
|
|
9
9
|
}
|
|
10
10
|
/** Trailing #tags (Yarn's line ids and metadata) don't belong to the text. */
|
|
11
|
-
const
|
|
11
|
+
const TAGS = /(\s+#[^\s#]+)+\s*$/;
|
|
12
|
+
const stripTags = (s) => s.replace(TAGS, "");
|
|
13
|
+
/** The id in a line's #line: tag, if it has one. */
|
|
14
|
+
function lineId(s, fail) {
|
|
15
|
+
const tags = TAGS.exec(s)?.[0].trim().split(/\s+/) ?? [];
|
|
16
|
+
const ids = tags.filter((t) => t.startsWith("#line:")).map((t) => t.slice(6));
|
|
17
|
+
if (ids.length > 1)
|
|
18
|
+
fail("a line can only have one #line: tag");
|
|
19
|
+
if (ids[0] !== undefined && !/^[A-Za-z0-9_-]+$/.test(ids[0]))
|
|
20
|
+
fail(`"#line:${ids[0]}": a line id is letters, digits, - and _`);
|
|
21
|
+
return ids[0];
|
|
22
|
+
}
|
|
12
23
|
const indentOf = (s) => /^[ \t]*/.exec(s)[0].replace(/\t/g, " ").length;
|
|
13
24
|
export function parseYarn(source, file = "<yarn>") {
|
|
14
25
|
const nodes = [];
|
|
@@ -131,8 +142,10 @@ class BodyParser {
|
|
|
131
142
|
if (text.startsWith("<<"))
|
|
132
143
|
this.fail(line, `expected <<command ...>>, got "${text}"`);
|
|
133
144
|
const clean = stripTags(text);
|
|
145
|
+
const id = lineId(text, (why) => this.fail(line, why));
|
|
134
146
|
const m = /^([A-Za-z][\w ]*?)\s*:\s+(.+)$/.exec(clean);
|
|
135
|
-
|
|
147
|
+
const said = m ? { speaker: m[1], text: m[2] } : { text: clean };
|
|
148
|
+
items.push({ kind: "line", ...said, ...(id === undefined ? {} : { id }), line });
|
|
136
149
|
}
|
|
137
150
|
return items;
|
|
138
151
|
}
|
|
@@ -212,3 +225,40 @@ export function parseExpr(source, fail) {
|
|
|
212
225
|
fail(`unexpected "${tokens[p]}" in "${source}"`);
|
|
213
226
|
return e;
|
|
214
227
|
}
|
|
228
|
+
/** Every line in some items, including those under choices and in <<if>> branches. */
|
|
229
|
+
export function yarnLines(items) {
|
|
230
|
+
return items.flatMap((x) => x.kind === "line" ? [x]
|
|
231
|
+
: x.kind === "choice" ? yarnLines(x.body)
|
|
232
|
+
: x.kind === "if" ? x.branches.flatMap((b) => yarnLines(b.body))
|
|
233
|
+
: []);
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Gives every line without a #line: tag one, `<prefix>-001` and on, skipping ids already
|
|
237
|
+
* `taken` (pass the game's, so they're unique across rooms). Lines that have an id keep it,
|
|
238
|
+
* and nothing else in the file changes.
|
|
239
|
+
*/
|
|
240
|
+
export function tagLines(source, prefix, taken, file = "<yarn>") {
|
|
241
|
+
const lines = parseYarn(source, file).flatMap((n) => yarnLines(n.body));
|
|
242
|
+
for (const l of lines)
|
|
243
|
+
if (l.id !== undefined)
|
|
244
|
+
taken.add(l.id);
|
|
245
|
+
const untagged = new Set(lines.filter((l) => l.id === undefined).map((l) => l.line));
|
|
246
|
+
const added = [];
|
|
247
|
+
let n = 0;
|
|
248
|
+
const next = () => {
|
|
249
|
+
let id;
|
|
250
|
+
do
|
|
251
|
+
id = `${prefix}-${String(++n).padStart(3, "0")}`;
|
|
252
|
+
while (taken.has(id));
|
|
253
|
+
taken.add(id);
|
|
254
|
+
added.push(id);
|
|
255
|
+
return id;
|
|
256
|
+
};
|
|
257
|
+
const out = source.split("\n").map((text, i) => {
|
|
258
|
+
if (!untagged.has(i + 1))
|
|
259
|
+
return text;
|
|
260
|
+
const cr = text.endsWith("\r") ? "\r" : "";
|
|
261
|
+
return `${text.slice(0, text.length - cr.length).trimEnd()} #line:${next()}${cr}`;
|
|
262
|
+
});
|
|
263
|
+
return { source: out.join("\n"), added };
|
|
264
|
+
}
|
|
@@ -43,6 +43,8 @@ export interface YarnLine {
|
|
|
43
43
|
kind: "line";
|
|
44
44
|
speaker?: string;
|
|
45
45
|
text: string;
|
|
46
|
+
/** Its #line: tag, which stays with it when lines around it change (recordings use it). */
|
|
47
|
+
id?: string;
|
|
46
48
|
line: number;
|
|
47
49
|
}
|
|
48
50
|
|
|
@@ -83,7 +85,16 @@ export class YarnError extends Error {
|
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
/** Trailing #tags (Yarn's line ids and metadata) don't belong to the text. */
|
|
86
|
-
const
|
|
88
|
+
const TAGS = /(\s+#[^\s#]+)+\s*$/;
|
|
89
|
+
const stripTags = (s: string) => s.replace(TAGS, "");
|
|
90
|
+
/** The id in a line's #line: tag, if it has one. */
|
|
91
|
+
function lineId(s: string, fail: (why: string) => never): string | undefined {
|
|
92
|
+
const tags = TAGS.exec(s)?.[0].trim().split(/\s+/) ?? [];
|
|
93
|
+
const ids = tags.filter((t) => t.startsWith("#line:")).map((t) => t.slice(6));
|
|
94
|
+
if (ids.length > 1) fail("a line can only have one #line: tag");
|
|
95
|
+
if (ids[0] !== undefined && !/^[A-Za-z0-9_-]+$/.test(ids[0])) fail(`"#line:${ids[0]}": a line id is letters, digits, - and _`);
|
|
96
|
+
return ids[0];
|
|
97
|
+
}
|
|
87
98
|
const indentOf = (s: string) => /^[ \t]*/.exec(s)![0].replace(/\t/g, " ").length;
|
|
88
99
|
|
|
89
100
|
export function parseYarn(source: string, file = "<yarn>"): YarnNode[] {
|
|
@@ -189,8 +200,10 @@ class BodyParser {
|
|
|
189
200
|
}
|
|
190
201
|
if (text.startsWith("<<")) this.fail(line, `expected <<command ...>>, got "${text}"`);
|
|
191
202
|
const clean = stripTags(text);
|
|
203
|
+
const id = lineId(text, (why) => this.fail(line, why));
|
|
192
204
|
const m = /^([A-Za-z][\w ]*?)\s*:\s+(.+)$/.exec(clean);
|
|
193
|
-
|
|
205
|
+
const said = m ? { speaker: m[1]!, text: m[2]! } : { text: clean };
|
|
206
|
+
items.push({ kind: "line", ...said, ...(id === undefined ? {} : { id }), line });
|
|
194
207
|
}
|
|
195
208
|
return items;
|
|
196
209
|
}
|
|
@@ -259,3 +272,39 @@ export function parseExpr(source: string, fail: (why: string) => never): YarnExp
|
|
|
259
272
|
if (p < tokens.length) fail(`unexpected "${tokens[p]}" in "${source}"`);
|
|
260
273
|
return e;
|
|
261
274
|
}
|
|
275
|
+
|
|
276
|
+
/** Every line in some items, including those under choices and in <<if>> branches. */
|
|
277
|
+
export function yarnLines(items: YarnItem[]): YarnLine[] {
|
|
278
|
+
return items.flatMap((x) =>
|
|
279
|
+
x.kind === "line" ? [x]
|
|
280
|
+
: x.kind === "choice" ? yarnLines(x.body)
|
|
281
|
+
: x.kind === "if" ? x.branches.flatMap((b) => yarnLines(b.body))
|
|
282
|
+
: []);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Gives every line without a #line: tag one, `<prefix>-001` and on, skipping ids already
|
|
287
|
+
* `taken` (pass the game's, so they're unique across rooms). Lines that have an id keep it,
|
|
288
|
+
* and nothing else in the file changes.
|
|
289
|
+
*/
|
|
290
|
+
export function tagLines(source: string, prefix: string, taken: Set<string>, file = "<yarn>"): { source: string; added: string[] } {
|
|
291
|
+
const lines = parseYarn(source, file).flatMap((n) => yarnLines(n.body));
|
|
292
|
+
for (const l of lines) if (l.id !== undefined) taken.add(l.id);
|
|
293
|
+
const untagged = new Set(lines.filter((l) => l.id === undefined).map((l) => l.line));
|
|
294
|
+
const added: string[] = [];
|
|
295
|
+
let n = 0;
|
|
296
|
+
const next = () => {
|
|
297
|
+
let id: string;
|
|
298
|
+
do id = `${prefix}-${String(++n).padStart(3, "0")}`;
|
|
299
|
+
while (taken.has(id));
|
|
300
|
+
taken.add(id);
|
|
301
|
+
added.push(id);
|
|
302
|
+
return id;
|
|
303
|
+
};
|
|
304
|
+
const out = source.split("\n").map((text, i) => {
|
|
305
|
+
if (!untagged.has(i + 1)) return text;
|
|
306
|
+
const cr = text.endsWith("\r") ? "\r" : "";
|
|
307
|
+
return `${text.slice(0, text.length - cr.length).trimEnd()} #line:${next()}${cr}`;
|
|
308
|
+
});
|
|
309
|
+
return { source: out.join("\n"), added };
|
|
310
|
+
}
|
|
@@ -29,4 +29,6 @@ export interface SolHeader {
|
|
|
29
29
|
export declare function parseSolHeader(data: Uint8Array): SolHeader | undefined;
|
|
30
30
|
/** Samples a SOL clip of `dataBytes` bytes decodes to, per channel. */
|
|
31
31
|
export declare function solSampleCount(flags: number, dataBytes: number): number;
|
|
32
|
+
/** The steps of 16-bit DPCM, by the low seven bits of a byte (bit 7 makes it a step down). */
|
|
33
|
+
export declare const DPCM16: number[];
|
|
32
34
|
export declare function decodeSol(data: Uint8Array): SolAudio | undefined;
|
|
@@ -17,7 +17,8 @@ export function solSampleCount(flags, dataBytes) {
|
|
|
17
17
|
const bytesPerSample = flags & SolFlag.Compressed ? (flags & SolFlag.Bits16 ? 1 : 0.5) : flags & SolFlag.Bits16 ? 2 : 1;
|
|
18
18
|
return Math.floor(dataBytes / bytesPerSample / channels);
|
|
19
19
|
}
|
|
20
|
-
|
|
20
|
+
/** The steps of 16-bit DPCM, by the low seven bits of a byte (bit 7 makes it a step down). */
|
|
21
|
+
export const DPCM16 = [
|
|
21
22
|
0x0000, 0x0008, 0x0010, 0x0020, 0x0030, 0x0040, 0x0050, 0x0060, 0x0070, 0x0080, 0x0090, 0x00a0, 0x00b0, 0x00c0,
|
|
22
23
|
0x00d0, 0x00e0, 0x00f0, 0x0100, 0x0110, 0x0120, 0x0130, 0x0140, 0x0150, 0x0160, 0x0170, 0x0180, 0x0190, 0x01a0,
|
|
23
24
|
0x01b0, 0x01c0, 0x01d0, 0x01e0, 0x01f0, 0x0200, 0x0208, 0x0210, 0x0218, 0x0220, 0x0228, 0x0230, 0x0238, 0x0240,
|
|
@@ -43,7 +43,8 @@ export function solSampleCount(flags: number, dataBytes: number): number {
|
|
|
43
43
|
return Math.floor(dataBytes / bytesPerSample / channels);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
/** The steps of 16-bit DPCM, by the low seven bits of a byte (bit 7 makes it a step down). */
|
|
47
|
+
export const DPCM16 = [
|
|
47
48
|
0x0000, 0x0008, 0x0010, 0x0020, 0x0030, 0x0040, 0x0050, 0x0060, 0x0070, 0x0080, 0x0090, 0x00a0, 0x00b0, 0x00c0,
|
|
48
49
|
0x00d0, 0x00e0, 0x00f0, 0x0100, 0x0110, 0x0120, 0x0130, 0x0140, 0x0150, 0x0160, 0x0170, 0x0180, 0x0190, 0x01a0,
|
|
49
50
|
0x01b0, 0x01c0, 0x01d0, 0x01e0, 0x01f0, 0x0200, 0x0208, 0x0210, 0x0218, 0x0220, 0x0228, 0x0230, 0x0238, 0x0240,
|
|
@@ -20,8 +20,43 @@ export interface SoundSpec {
|
|
|
20
20
|
}[];
|
|
21
21
|
}
|
|
22
22
|
export declare function writeSound(spec: SoundSpec): Uint8Array;
|
|
23
|
-
/**
|
|
24
|
-
|
|
23
|
+
/**
|
|
24
|
+
* A SOL clip of 16-bit samples at `rate`: uncompressed, or compressed with SOL's 16-bit DPCM
|
|
25
|
+
* (one byte a sample, half the size; what speech uses).
|
|
26
|
+
*/
|
|
27
|
+
export declare function writeSol(samples: Int16Array, rate: number, compressed?: boolean): Uint8Array;
|
|
28
|
+
/**
|
|
29
|
+
* SOL's 16-bit DPCM: each byte steps from the last sample by DPCM16[byte & 0x7f], down if
|
|
30
|
+
* bit 7 is set. Each step is the one that lands nearest the sample, never past the 16-bit
|
|
31
|
+
* range (the decoder would wrap around).
|
|
32
|
+
*/
|
|
33
|
+
export declare function encodeDpcm16(samples: Int16Array): Uint8Array;
|
|
34
|
+
export interface SpeechClip {
|
|
35
|
+
/** The message file (room) the line is in, and its key there. */
|
|
36
|
+
module: number;
|
|
37
|
+
noun: number;
|
|
38
|
+
verb: number;
|
|
39
|
+
cond: number;
|
|
40
|
+
seq: number;
|
|
41
|
+
sol: Uint8Array;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* RESOURCE.AUD (SOL clips one after another, by module and key) and a map per module, in the
|
|
45
|
+
* layout AudioIndex reads: u32 offset of the module's first clip, then per clip u32
|
|
46
|
+
* big-endian noun<<24|verb<<16|cond<<8|seq and a u24 delta from the previous clip;
|
|
47
|
+
* 0xFFFFFFFF ends it.
|
|
48
|
+
*
|
|
49
|
+
* The index takes a clip's length from where the next one starts, so the last map also has
|
|
50
|
+
* an entry at the end of the file, under 0 0 0 0, which no message has (nouns and seqs
|
|
51
|
+
* start at 1); without it the last clip's length would be a guess.
|
|
52
|
+
*/
|
|
53
|
+
export declare function writeSpeech(clips: readonly SpeechClip[]): {
|
|
54
|
+
aud: Uint8Array;
|
|
55
|
+
maps: {
|
|
56
|
+
module: number;
|
|
57
|
+
data: Uint8Array;
|
|
58
|
+
}[];
|
|
59
|
+
};
|
|
25
60
|
/**
|
|
26
61
|
* RESOURCE.SFX (the SOL clips, one after another) and its index, map 65535: per clip, u16
|
|
27
62
|
* sound number and u24 offset from the previous clip; 0xFFFF ends it.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SolFlag } from "./sol.js";
|
|
1
|
+
import { DPCM16, SolFlag } from "./sol.js";
|
|
2
2
|
const dataBytes = (status) => ((status & 0xf0) === 0xc0 || (status & 0xf0) === 0xd0 ? 1 : 2);
|
|
3
3
|
/** A channel stream: channel, voices, then delta-timed events with running status, then 0xFC. */
|
|
4
4
|
function channelStream(c) {
|
|
@@ -51,19 +51,108 @@ export function writeSound(spec) {
|
|
|
51
51
|
table.push(0xff);
|
|
52
52
|
return Uint8Array.from([...header, ...table, ...body]);
|
|
53
53
|
}
|
|
54
|
-
/**
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
/**
|
|
55
|
+
* A SOL clip of 16-bit samples at `rate`: uncompressed, or compressed with SOL's 16-bit DPCM
|
|
56
|
+
* (one byte a sample, half the size; what speech uses).
|
|
57
|
+
*/
|
|
58
|
+
export function writeSol(samples, rate, compressed = false) {
|
|
59
|
+
const data = compressed ? encodeDpcm16(samples) : new Uint8Array(samples.buffer, samples.byteOffset, samples.byteLength);
|
|
60
|
+
const out = new Uint8Array(13 + data.length);
|
|
57
61
|
const v = new DataView(out.buffer);
|
|
58
62
|
out[0] = 0x8d; // audio
|
|
59
63
|
out[1] = 11; // header bytes after these two
|
|
60
64
|
out.set([0x53, 0x4f, 0x4c, 0], 2); // "SOL\0"
|
|
61
65
|
v.setUint16(6, rate, true);
|
|
62
|
-
out[8] = SolFlag.Bits16 | SolFlag.Signed;
|
|
63
|
-
v.setUint32(9,
|
|
64
|
-
|
|
66
|
+
out[8] = compressed ? SolFlag.Compressed | SolFlag.Bits16 : SolFlag.Bits16 | SolFlag.Signed;
|
|
67
|
+
v.setUint32(9, data.length, true);
|
|
68
|
+
if (compressed)
|
|
69
|
+
out.set(data, 13);
|
|
70
|
+
else
|
|
71
|
+
samples.forEach((s, i) => v.setInt16(13 + i * 2, s, true));
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* SOL's 16-bit DPCM: each byte steps from the last sample by DPCM16[byte & 0x7f], down if
|
|
76
|
+
* bit 7 is set. Each step is the one that lands nearest the sample, never past the 16-bit
|
|
77
|
+
* range (the decoder would wrap around).
|
|
78
|
+
*/
|
|
79
|
+
export function encodeDpcm16(samples) {
|
|
80
|
+
const out = new Uint8Array(samples.length);
|
|
81
|
+
let last = 0;
|
|
82
|
+
for (let i = 0; i < samples.length; i++) {
|
|
83
|
+
const want = samples[i] - last, size = Math.abs(want);
|
|
84
|
+
let lo = 0, hi = DPCM16.length - 1;
|
|
85
|
+
while (lo < hi) {
|
|
86
|
+
const mid = (lo + hi) >> 1;
|
|
87
|
+
if (DPCM16[mid] < size)
|
|
88
|
+
lo = mid + 1;
|
|
89
|
+
else
|
|
90
|
+
hi = mid;
|
|
91
|
+
}
|
|
92
|
+
// lo is the first step at least as big; the one below may be nearer.
|
|
93
|
+
if (lo > 0 && size - DPCM16[lo - 1] < DPCM16[lo] - size)
|
|
94
|
+
lo--;
|
|
95
|
+
const down = want < 0;
|
|
96
|
+
const land = (k) => last + (down ? -DPCM16[k] : DPCM16[k]);
|
|
97
|
+
while (lo > 0 && (land(lo) > 32767 || land(lo) < -32768))
|
|
98
|
+
lo--;
|
|
99
|
+
out[i] = (down ? 0x80 : 0) | lo;
|
|
100
|
+
last = land(lo);
|
|
101
|
+
}
|
|
65
102
|
return out;
|
|
66
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* RESOURCE.AUD (SOL clips one after another, by module and key) and a map per module, in the
|
|
106
|
+
* layout AudioIndex reads: u32 offset of the module's first clip, then per clip u32
|
|
107
|
+
* big-endian noun<<24|verb<<16|cond<<8|seq and a u24 delta from the previous clip;
|
|
108
|
+
* 0xFFFFFFFF ends it.
|
|
109
|
+
*
|
|
110
|
+
* The index takes a clip's length from where the next one starts, so the last map also has
|
|
111
|
+
* an entry at the end of the file, under 0 0 0 0, which no message has (nouns and seqs
|
|
112
|
+
* start at 1); without it the last clip's length would be a guess.
|
|
113
|
+
*/
|
|
114
|
+
export function writeSpeech(clips) {
|
|
115
|
+
const key = (c) => ((c.noun << 24) | (c.verb << 16) | (c.cond << 8) | c.seq) >>> 0;
|
|
116
|
+
const sorted = [...clips].sort((a, b) => a.module - b.module || key(a) - key(b));
|
|
117
|
+
const aud = new Uint8Array(sorted.reduce((n, c) => n + c.sol.length, 0));
|
|
118
|
+
const modules = [...new Set(sorted.map((c) => c.module))];
|
|
119
|
+
let at = 0;
|
|
120
|
+
const maps = modules.map((module, m) => {
|
|
121
|
+
const mine = sorted.filter((c) => c.module === module);
|
|
122
|
+
const last = m === modules.length - 1;
|
|
123
|
+
const data = new Uint8Array(4 + (mine.length + (last ? 1 : 0)) * 7 + 4);
|
|
124
|
+
const v = new DataView(data.buffer);
|
|
125
|
+
v.setUint32(0, at, true);
|
|
126
|
+
let prev = at, p = 4;
|
|
127
|
+
const entry = (k) => {
|
|
128
|
+
const delta = at - prev;
|
|
129
|
+
if (delta > 0xffffff)
|
|
130
|
+
throw new Error("speech clip too large (16 MB at most)");
|
|
131
|
+
v.setUint32(p, k, false);
|
|
132
|
+
data.set([delta & 0xff, (delta >> 8) & 0xff, delta >> 16], p + 4);
|
|
133
|
+
p += 7;
|
|
134
|
+
prev = at;
|
|
135
|
+
};
|
|
136
|
+
mine.forEach((c, i) => {
|
|
137
|
+
const where = `${c.module} ${c.noun} ${c.verb} ${c.cond} ${c.seq}`;
|
|
138
|
+
for (const [n, max] of [[c.noun, 255], [c.verb, 255], [c.cond, 255]])
|
|
139
|
+
if (n < 0 || n > max)
|
|
140
|
+
throw new Error(`speech for ${where}: noun, verb and cond are 0-255`);
|
|
141
|
+
if (c.seq < 1 || c.seq > 63)
|
|
142
|
+
throw new Error(`speech for ${where}: seq is 1-63`);
|
|
143
|
+
if (i && key(c) === key(mine[i - 1]))
|
|
144
|
+
throw new Error(`speech for ${where} is there twice`);
|
|
145
|
+
entry(key(c));
|
|
146
|
+
aud.set(c.sol, at);
|
|
147
|
+
at += c.sol.length;
|
|
148
|
+
});
|
|
149
|
+
if (last)
|
|
150
|
+
entry(0);
|
|
151
|
+
v.setUint32(p, 0xffffffff, false);
|
|
152
|
+
return { module, data };
|
|
153
|
+
});
|
|
154
|
+
return { aud, maps };
|
|
155
|
+
}
|
|
67
156
|
/**
|
|
68
157
|
* RESOURCE.SFX (the SOL clips, one after another) and its index, map 65535: per clip, u16
|
|
69
158
|
* sound number and u24 offset from the previous clip; 0xFFFF ends it.
|