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 CHANGED
@@ -4,6 +4,18 @@ All notable changes to this project. Versions follow [Semantic Versioning](https
4
4
  and the entries are generated from commit messages
5
5
  ([Conventional Commits](https://www.conventionalcommits.org/)) when a release is made.
6
6
 
7
+ ## [0.15.1](https://github.com/rlueder/sci-ts/compare/v0.15.0...v0.15.1) (2026-10-02)
8
+
9
+ ### Fixed
10
+
11
+ * **viewer:** fetch game files with the build's version ([#18](https://github.com/rlueder/sci-ts/issues/18)) ([370e490](https://github.com/rlueder/sci-ts/commit/370e490d9c618d78223fbba1df4850a8c6ebb0a2))
12
+
13
+ ## [0.15.0](https://github.com/rlueder/sci-ts/compare/v0.14.0...v0.15.0) (2026-10-02)
14
+
15
+ ### Added
16
+
17
+ * bitmap fonts with bearings and advances, styled text, and skinned icon bar and inventory ([#17](https://github.com/rlueder/sci-ts/issues/17)) ([0cec83b](https://github.com/rlueder/sci-ts/commit/0cec83be7211ee532c6846a08826a2b09f462ee1))
18
+
7
19
  ## [0.14.0](https://github.com/rlueder/sci-ts/compare/v0.13.0...v0.14.0) (2026-10-02)
8
20
 
9
21
  ### Added
@@ -4,6 +4,11 @@ export declare const BASE: string;
4
4
  /**
5
5
  * FileSource over game/: the dev server's mount (see vite.config.ts), or the game copied
6
6
  * next to a built site, with its file names in game/files.json (`pnpm game site`).
7
+ *
8
+ * A built site's files.json also has the build's `version`. It's always fetched fresh, and
9
+ * every game file is fetched with the version on its URL, so after a new build a browser
10
+ * can't mix its cached files with the new ones (an old RESOURCE.000 under a new RESOURCE.MAP
11
+ * doesn't start).
7
12
  */
8
13
  export declare const httpFiles: FileSource;
9
14
  /**
@@ -5,14 +5,19 @@ export const BASE = import.meta.env.BASE_URL;
5
5
  /**
6
6
  * FileSource over game/: the dev server's mount (see vite.config.ts), or the game copied
7
7
  * next to a built site, with its file names in game/files.json (`pnpm game site`).
8
+ *
9
+ * A built site's files.json also has the build's `version`. It's always fetched fresh, and
10
+ * every game file is fetched with the version on its URL, so after a new build a browser
11
+ * can't mix its cached files with the new ones (an old RESOURCE.000 under a new RESOURCE.MAP
12
+ * doesn't start).
8
13
  */
9
14
  export const httpFiles = {
10
15
  async read(path) {
11
- const res = await fetch(`${BASE}game/${path}`);
16
+ const res = await fetch(await gameUrl(path));
12
17
  return res.ok ? new Uint8Array(await res.arrayBuffer()) : undefined;
13
18
  },
14
19
  async readRange(path, offset, length) {
15
- const res = await fetch(`${BASE}game/${path}`, { headers: { Range: `bytes=${offset}-${offset + length - 1}` } });
20
+ const res = await fetch(await gameUrl(path), { headers: { Range: `bytes=${offset}-${offset + length - 1}` } });
16
21
  const bytes = new Uint8Array(await res.arrayBuffer());
17
22
  // A server that ignores Range (Python's http.server) sends the whole file.
18
23
  return res.status === 206 ? bytes : bytes.subarray(offset, offset + length);
@@ -20,11 +25,20 @@ export const httpFiles = {
20
25
  async list(dir) {
21
26
  if (import.meta.env.DEV)
22
27
  return (await fetch(`/__list?dir=${encodeURIComponent(dir)}`)).json();
23
- staticListing ??= fetch(`${BASE}game/files.json`).then((r) => (r.ok ? r.json() : {}));
24
- return (await staticListing)[dir] ?? [];
28
+ const files = (await staticListing())[dir];
29
+ return Array.isArray(files) ? files : [];
25
30
  },
26
31
  };
27
- let staticListing;
32
+ let listing;
33
+ const staticListing = () => (listing ??= fetch(`${BASE}game/files.json`, { cache: "no-store" }).then((r) => (r.ok ? r.json() : Promise.resolve({}))));
34
+ /** A game file's URL: on a built site, with the build's version (see httpFiles). */
35
+ async function gameUrl(path) {
36
+ const url = `${BASE}game/${path}`;
37
+ if (import.meta.env.DEV)
38
+ return url;
39
+ const version = (await staticListing()).version;
40
+ return typeof version === "string" ? `${url}?v=${encodeURIComponent(version)}` : url;
41
+ }
28
42
  // --- Mods -------------------------------------------------------------------------------
29
43
  const MODS_KEY = `${APP_ID}.mods`;
30
44
  /**
@@ -7,25 +7,40 @@ export const BASE = import.meta.env.BASE_URL;
7
7
  /**
8
8
  * FileSource over game/: the dev server's mount (see vite.config.ts), or the game copied
9
9
  * next to a built site, with its file names in game/files.json (`pnpm game site`).
10
+ *
11
+ * A built site's files.json also has the build's `version`. It's always fetched fresh, and
12
+ * every game file is fetched with the version on its URL, so after a new build a browser
13
+ * can't mix its cached files with the new ones (an old RESOURCE.000 under a new RESOURCE.MAP
14
+ * doesn't start).
10
15
  */
11
16
  export const httpFiles: FileSource = {
12
17
  async read(path) {
13
- const res = await fetch(`${BASE}game/${path}`);
18
+ const res = await fetch(await gameUrl(path));
14
19
  return res.ok ? new Uint8Array(await res.arrayBuffer()) : undefined;
15
20
  },
16
21
  async readRange(path, offset, length) {
17
- const res = await fetch(`${BASE}game/${path}`, { headers: { Range: `bytes=${offset}-${offset + length - 1}` } });
22
+ const res = await fetch(await gameUrl(path), { headers: { Range: `bytes=${offset}-${offset + length - 1}` } });
18
23
  const bytes = new Uint8Array(await res.arrayBuffer());
19
24
  // A server that ignores Range (Python's http.server) sends the whole file.
20
25
  return res.status === 206 ? bytes : bytes.subarray(offset, offset + length);
21
26
  },
22
27
  async list(dir) {
23
28
  if (import.meta.env.DEV) return (await fetch(`/__list?dir=${encodeURIComponent(dir)}`)).json();
24
- staticListing ??= fetch(`${BASE}game/files.json`).then((r) => (r.ok ? r.json() : {}));
25
- return (await staticListing)[dir] ?? [];
29
+ const files = (await staticListing())[dir];
30
+ return Array.isArray(files) ? files : [];
26
31
  },
27
32
  };
28
- let staticListing: Promise<Record<string, string[]>> | undefined;
33
+
34
+ let listing: Promise<Record<string, unknown>> | undefined;
35
+ const staticListing = () => (listing ??= fetch(`${BASE}game/files.json`, { cache: "no-store" }).then((r): Promise<Record<string, unknown>> => (r.ok ? r.json() : Promise.resolve({}))));
36
+
37
+ /** A game file's URL: on a built site, with the build's version (see httpFiles). */
38
+ async function gameUrl(path: string): Promise<string> {
39
+ const url = `${BASE}game/${path}`;
40
+ if (import.meta.env.DEV) return url;
41
+ const version = (await staticListing()).version;
42
+ return typeof version === "string" ? `${url}?v=${encodeURIComponent(version)}` : url;
43
+ }
29
44
 
30
45
  // --- Mods -------------------------------------------------------------------------------
31
46
 
package/lib/991.sc CHANGED
@@ -13,50 +13,63 @@
13
13
  (return (and (>= theX x) (< theX (+ x size)) (>= theY y) (< theY (+ y size))))))
14
14
 
15
15
  ;; Slides down when the pointer reaches the top edge of the screen, or on a tap there. Its
16
- ;; icons are the cels of `view`, loop 0 as they are and loop 1 picked, each ICON_SIZE
16
+ ;; icons are the cels of `view`, loop 0 as they are and loop 1 picked, each `size`
17
17
  ;; square and anchored at the top-left corner: walk, look, do, talk, the inventory, the
18
18
  ;; game menu (the item in use shows its own icon). A game with its own sets view in its
19
19
  ;; init: (iconBar view: 266). Picking one, or a click below the bar, closes it.
20
+ ;; With `skin`, a view drawn across the top of the screen (loop 0, cel 0, anchored at its top
21
+ ;; left) instead of the box, the icons `size` square at `left`, `left` + `spacing` and on, at
22
+ ;; `top`; the item in use goes in the fifth place, between the verbs and the inventory.
23
+ ;; (iconBar view: 266 skin: 267 size: 32 left: 16 spacing: 42 top: 8)
20
24
  (class IconBar of Obj
21
25
  (properties
22
26
  view ICON_BAR_VIEW
27
+ skin -1
28
+ size ICON_SIZE
29
+ left -1 ; -1 here and below: laid out inside the box
30
+ spacing -1
31
+ top -1
23
32
  box 0
24
33
  icons 0
25
34
  height 0)
26
35
 
27
- (method (show &tmp x)
36
+ (method (show &tmp x y step)
28
37
  (if box (return))
29
- (= box
30
- ((TextItem new:)
31
- text: ""
32
- width: SCREEN_WIDTH
33
- height: (+ ICON_SIZE (* 2 ICON_GAP) (* 2 (textStyle margin?)))
34
- x: 0 y: 0
35
- yourself:))
36
- (if (!= (textStyle frame?) -1)
37
- (box height: (+ (box height?) (CelHigh (textStyle frame?) 0 4) (CelHigh (textStyle frame?) 0 5))))
38
- (box init:)
39
- (= height (box height?))
38
+ (if (!= skin -1)
39
+ (= box ((View new:) view: skin loop: 0 cel: 0 x: 0 y: 0 yourself:))
40
+ (box init:)
41
+ (box plane: uiPlane setPri: 100)
42
+ (= height (CelHigh skin 0 0))
43
+ (= x (if (!= left -1) left else ICON_GAP))
44
+ (= y (if (!= top -1) top else ICON_GAP))
45
+ else
46
+ (= box
47
+ ((TextItem new:)
48
+ text: ""
49
+ width: SCREEN_WIDTH
50
+ height: (+ size (* 2 ICON_GAP) (* 2 (textStyle margin?)))
51
+ x: 0 y: 0
52
+ yourself:))
53
+ (if (!= (textStyle frame?) -1)
54
+ (box height: (+ (box height?) (CelHigh (textStyle frame?) 0 4) (CelHigh (textStyle frame?) 0 5))))
55
+ (box init:)
56
+ (= height (box height?))
57
+ (= x (if (!= left -1) left else (+ (box textLeft?) ICON_GAP)))
58
+ (= y (if (!= top -1) top else (+ (box textTop?) ICON_GAP))))
59
+ (= step (if (!= spacing -1) spacing else (+ size ICON_GAP)))
40
60
  (= icons (List new:))
41
- (= x (+ (box textLeft?) ICON_GAP))
42
61
  ;; The verbs, then the item, the inventory and the menu.
43
- (self addIcon: x 0 V_WALK)
44
- (+= x (+ ICON_SIZE ICON_GAP))
45
- (self addIcon: x 1 V_LOOK)
46
- (+= x (+ ICON_SIZE ICON_GAP))
47
- (self addIcon: x 2 V_DO)
48
- (+= x (+ ICON_SIZE ICON_GAP))
49
- (self addIcon: x 3 V_TALK)
50
- (+= x (+ ICON_SIZE ICON_GAP))
51
- (self addIcon: x -1 V_ITEM)
52
- (+= x (+ ICON_SIZE ICON_GAP))
53
- (self addIcon: x 4 ICON_INVENTORY)
54
- (+= x (+ ICON_SIZE ICON_GAP))
55
- (self addIcon: x 5 ICON_MENU)
62
+ (self addIcon: x y 0 V_WALK)
63
+ (self addIcon: (+ x step) y 1 V_LOOK)
64
+ (self addIcon: (+ x (* 2 step)) y 2 V_DO)
65
+ (self addIcon: (+ x (* 3 step)) y 3 V_TALK)
66
+ (self addIcon: (+ x (* 4 step)) y -1 V_ITEM)
67
+ (self addIcon: (+ x (* 5 step)) y 4 ICON_INVENTORY)
68
+ (self addIcon: (+ x (* 6 step)) y 5 ICON_MENU)
56
69
  (= dialog self))
57
70
 
58
- ;; An icon at x: cel c of the bar's view (-1: the item in use), standing for `what`.
59
- (method (addIcon theX c what &tmp icon)
71
+ ;; An icon at (x, y): cel c of the bar's view (-1: the item in use), standing for `what`.
72
+ (method (addIcon theX theY c what &tmp icon)
60
73
  (if (and (== c -1) (not theItem)) (return))
61
74
  (= icon
62
75
  ((Icon new:)
@@ -64,8 +77,9 @@
64
77
  loop: (if (and (!= c -1) (== what (user verb?))) 1 else 0)
65
78
  cel: (if (== c -1) 0 else c)
66
79
  actions: what
80
+ size: size
67
81
  x: theX
68
- y: (+ (box textTop?) ICON_GAP)
82
+ y: theY
69
83
  yourself:))
70
84
  (icon init:)
71
85
  (icon plane: uiPlane setPri: (+ (box priority?) 1))
package/lib/993.sc CHANGED
@@ -21,16 +21,27 @@
21
21
  ;; What the hero carries (the `inventory` global): (inventory add: lens), (inventory
22
22
  ;; contains: lens). showSelf opens the window: the items in a row, one click picks one to
23
23
  ;; use (it becomes the cursor), a right-click on one looks at it, a click elsewhere closes.
24
+ ;;
25
+ ;; With `skin`, the window is that view (loop 0, cel 0, anchored at its top left) at x, y,
26
+ ;; and the items go in a grid on it: `cols` across, the first slot at (slotLeft, slotTop) in
27
+ ;; the skin and the rest slotWidth and slotHeight on, each icon `inset` into its slot.
28
+ ;; (inventory skin: 268 x: 32 y: 30 cols: 4 slotLeft: 13 slotTop: 28 slotWidth: 60
29
+ ;; slotHeight: 47 inset: 7 iconSize: 32)
24
30
  (class Inventory of Set
25
31
  (properties
26
32
  window 0
27
33
  icons 0
28
- y 24)
34
+ x -1 ; -1: centred
35
+ y 24
36
+ skin -1
37
+ cols 4 slotLeft 0 slotTop 0 slotWidth 0 slotHeight 0 inset 0
38
+ iconSize INV_ICON)
29
39
 
30
- (method (showSelf &tmp node item icon x)
40
+ (method (showSelf &tmp node item icon ix i)
31
41
  (if (not size)
32
42
  (narrator say: "You aren't carrying anything.")
33
43
  (return))
44
+ (if (!= skin -1) (self showCase:) (return))
34
45
  (= window
35
46
  ((TextItem new:)
36
47
  text: ""
@@ -41,17 +52,40 @@
41
52
  (window
42
53
  width: (+ (window width?) (CelWide (textStyle frame?) 0 6) (CelWide (textStyle frame?) 0 7))
43
54
  height: (+ (window height?) (CelHigh (textStyle frame?) 0 4) (CelHigh (textStyle frame?) 0 5))))
44
- (window x: (/ (- SCREEN_WIDTH (window width?)) 2) y: y init:)
55
+ (window x: (if (== x -1) (/ (- SCREEN_WIDTH (window width?)) 2) else x) y: y init:)
45
56
  (= icons (List new:))
46
- (= x (+ (window x?) (window textLeft?) INV_GAP))
57
+ (= ix (+ (window x?) (window textLeft?) INV_GAP))
47
58
  (for ((= node (FirstNode elements))) node ((= node (NextNode node)))
48
59
  (= item (NodeValue node))
49
60
  ;; The icon keeps its item in `actions`.
50
- (= icon ((Icon new:) view: (item view?) loop: 0 cel: 0 actions: item x: x y: (+ (window y?) (window textTop?) INV_GAP) yourself:))
61
+ (= icon ((Icon new:) view: (item view?) loop: 0 cel: 0 actions: item size: iconSize x: ix y: (+ (window y?) (window textTop?) INV_GAP) yourself:))
51
62
  (icon init:)
52
63
  (icon plane: uiPlane setPri: (+ (window priority?) 1))
53
64
  (icons add: icon)
54
- (+= x (+ INV_ICON INV_GAP)))
65
+ (+= ix (+ iconSize INV_GAP)))
66
+ (= dialog self))
67
+
68
+ ;; The window as the skin, with the items in its slots (as many as it has).
69
+ (method (showCase &tmp node item icon i wx wy)
70
+ (= wx (if (== x -1) (/ (- SCREEN_WIDTH (CelWide skin 0 0)) 2) else x))
71
+ (= wy y)
72
+ (= window ((View new:) view: skin loop: 0 cel: 0 x: wx y: wy yourself:))
73
+ (window init:)
74
+ (window plane: uiPlane setPri: 100)
75
+ (= icons (List new:))
76
+ (= i 0)
77
+ (for ((= node (FirstNode elements))) node ((= node (NextNode node)))
78
+ (= item (NodeValue node))
79
+ (= icon
80
+ ((Icon new:)
81
+ view: (item view?) loop: 0 cel: 0 actions: item size: iconSize
82
+ x: (+ wx slotLeft (* (mod i cols) slotWidth) inset)
83
+ y: (+ wy slotTop (* (/ i cols) slotHeight) inset)
84
+ yourself:))
85
+ (icon init:)
86
+ (icon plane: uiPlane setPri: 101)
87
+ (icons add: icon)
88
+ (++ i))
55
89
  (= dialog self))
56
90
 
57
91
  ;; The item whose icon is at (x, y), or 0.
package/lib/996.sc CHANGED
@@ -20,7 +20,8 @@
20
20
  margin 4 ; between the text and the border or frame
21
21
  portraitFrame -1 ; a view around portraits: loop 0 behind the face, loop 1 over its edges
22
22
  portraitX 8 ; where a portrait's face goes on the left (mirrored on the right)
23
- portraitY 8))
23
+ portraitY 8
24
+ nameFont -1)) ; the font for a speaker's name before their line (-1: the text's)
24
25
 
25
26
  ;; A box of text over the room: drawn by the interpreter into a bitmap, shown as a screen
26
27
  ;; item in the UI plane. Set text (and width, x, y), then init; it's as tall as the text
@@ -183,14 +184,18 @@
183
184
  (= caller 0)
184
185
  (if c (c cue:))))
185
186
 
186
- ;; Someone with a name: their lines start with it.
187
+ ;; Someone with a name: their lines start with it (in the textStyle's nameFont, if it has one).
187
188
  (class Talker of Narrator
188
189
  (properties
189
190
  line 0)
190
191
 
191
192
  (method (say txt whoCares)
192
193
  (if line (String ARRAY_FREE line))
193
- (= line (String 11 "%s: %s" name txt))
194
+ (= line
195
+ (if (!= (textStyle nameFont?) -1)
196
+ (String 11 "|f%d|%s:|f| %s" (textStyle nameFont?) name txt)
197
+ else
198
+ (String 11 "%s: %s" name txt)))
194
199
  (super say: line &rest whoCares)))
195
200
 
196
201
  ;; Someone with a portrait: a view whose loop 0 is the bust, loop 1 the mouth and loop 2 the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sci2-ts",
3
- "version": "0.14.0",
3
+ "version": "0.15.1",
4
4
  "description": "An SCI2 adventure game engine in TypeScript, and the tools to make games with it",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -760,7 +760,8 @@ class RoomCompiler {
760
760
  message(noun, verb, cond, seq, l, label, spoken = true) {
761
761
  const where = `${this.yarnFile}:${l.line}`;
762
762
  const prev = this.messages.at(-1);
763
- const m = { noun, verb, cond, seq, talker: this.talkerOf(l.speaker, where), speaker: l.speaker ?? "Narrator", text: plainText(l.text, where), label };
763
+ const shown = styledText(plainText(l.text, l.id === undefined ? where : `${where} (#line:${l.id})`), this.target.fonts, where);
764
+ const m = { noun, verb, cond, seq, talker: this.talkerOf(l.speaker, where), speaker: l.speaker ?? "Narrator", text: shown.text, label };
764
765
  this.messages.push(m);
765
766
  if (!spoken)
766
767
  return;
@@ -768,8 +769,8 @@ class RoomCompiler {
768
769
  if (twice)
769
770
  throw new ContentError(`${where}: #line:${l.id} is already the id of ${twice.where}`);
770
771
  this.lines.push({
771
- ...(l.id === undefined ? {} : { id: l.id }), noun, verb, cond, seq, talker: m.talker, speaker: m.speaker, text: m.text, node: label,
772
- ...(prev?.label === label ? { before: { speaker: prev.speaker, text: prev.text } } : {}), where,
772
+ ...(l.id === undefined ? {} : { id: l.id }), noun, verb, cond, seq, talker: m.talker, speaker: m.speaker, text: shown.plain, node: label,
773
+ ...(prev?.label === label ? { before: { speaker: prev.speaker, text: stripCodes(prev.text) } } : {}), where,
773
774
  });
774
775
  }
775
776
  talkerOf(speaker, where) {
@@ -1056,6 +1057,48 @@ const counter = (from) => {
1056
1057
  return () => n++;
1057
1058
  };
1058
1059
  /** Message text is single bytes: typographic punctuation becomes ASCII, anything else is an error. */
1060
+ /** Message text without SCI's font and colour codes: what's said, and what recordings are for. */
1061
+ const stripCodes = (text) => text.replace(/\|[fc]\d*\|/g, "");
1062
+ /**
1063
+ * Yarn's [b] and [i] markup (nestable, closed with [/b] and [/i]) as SCI's font codes: bold,
1064
+ * italic or both, back to the text's own font with |f|. `plain` is the text without it, for
1065
+ * speech and the script. \[ is a bracket.
1066
+ */
1067
+ function styledText(text, fonts, where) {
1068
+ if (!text.includes("["))
1069
+ return { text, plain: text };
1070
+ const fail = (why) => { throw new ContentError(`${where}: ${why}`); };
1071
+ let shown = "", plain = "", bold = 0, italic = 0;
1072
+ const font = () => (bold && italic ? fonts.boldItalic : bold ? fonts.bold : italic ? fonts.italic : 0);
1073
+ for (let i = 0; i < text.length;) {
1074
+ if (text.startsWith("\\[", i)) {
1075
+ (shown += "["), (plain += "["), (i += 2);
1076
+ continue;
1077
+ }
1078
+ const m = /^\[(\/?)([a-z]+)\]/.exec(text.slice(i));
1079
+ if (text[i] === "[") {
1080
+ if (!m || (m[2] !== "b" && m[2] !== "i"))
1081
+ fail(`unknown markup "${m?.[0] ?? text.slice(i, i + 8)}" (supported: [b] and [i], closed with [/b] and [/i]; \\[ for a bracket)`);
1082
+ if (!fonts)
1083
+ fail("[b] and [i] need the game's bold and italic fonts (\"fonts\" in game.json)");
1084
+ const delta = m[1] ? -1 : 1;
1085
+ if (m[2] === "b")
1086
+ bold += delta;
1087
+ else
1088
+ italic += delta;
1089
+ if (bold < 0 || italic < 0)
1090
+ fail(`${m[0]} without its opening`);
1091
+ const f = font();
1092
+ shown += f ? `|f${f}|` : "|f|";
1093
+ i += m[0].length;
1094
+ continue;
1095
+ }
1096
+ (shown += text[i]), (plain += text[i]), i++;
1097
+ }
1098
+ if (bold || italic)
1099
+ fail(`[${bold ? "b" : "i"}] isn't closed`);
1100
+ return { text: shown.replace(/\|f\d*\|$/, (code) => (code === "|f|" ? "" : code)), plain };
1101
+ }
1059
1102
  function plainText(text, where) {
1060
1103
  const out = text
1061
1104
  .replace(/[‘’]/g, "'")
@@ -902,14 +902,15 @@ class RoomCompiler {
902
902
  private message(noun: number, verb: number, cond: number, seq: number, l: YarnLine, label: string, spoken = true): void {
903
903
  const where = `${this.yarnFile}:${l.line}`;
904
904
  const prev = this.messages.at(-1);
905
- const m: Message = { noun, verb, cond, seq, talker: this.talkerOf(l.speaker, where), speaker: l.speaker ?? "Narrator", text: plainText(l.text, where), label };
905
+ const shown = styledText(plainText(l.text, l.id === undefined ? where : `${where} (#line:${l.id})`), this.target.fonts, where);
906
+ const m: Message = { noun, verb, cond, seq, talker: this.talkerOf(l.speaker, where), speaker: l.speaker ?? "Narrator", text: shown.text, label };
906
907
  this.messages.push(m);
907
908
  if (!spoken) return;
908
909
  const twice = l.id !== undefined && this.lines.find((x) => x.id === l.id);
909
910
  if (twice) throw new ContentError(`${where}: #line:${l.id} is already the id of ${twice.where}`);
910
911
  this.lines.push({
911
- ...(l.id === undefined ? {} : { id: l.id }), noun, verb, cond, seq, talker: m.talker, speaker: m.speaker, text: m.text, node: label,
912
- ...(prev?.label === label ? { before: { speaker: prev.speaker, text: prev.text } } : {}), where,
912
+ ...(l.id === undefined ? {} : { id: l.id }), noun, verb, cond, seq, talker: m.talker, speaker: m.speaker, text: shown.plain, node: label,
913
+ ...(prev?.label === label ? { before: { speaker: prev.speaker, text: stripCodes(prev.text) } } : {}), where,
913
914
  });
914
915
  }
915
916
 
@@ -1214,6 +1215,43 @@ const counter = (from: number) => {
1214
1215
  };
1215
1216
 
1216
1217
  /** Message text is single bytes: typographic punctuation becomes ASCII, anything else is an error. */
1218
+ /** Message text without SCI's font and colour codes: what's said, and what recordings are for. */
1219
+ const stripCodes = (text: string) => text.replace(/\|[fc]\d*\|/g, "");
1220
+
1221
+ /**
1222
+ * Yarn's [b] and [i] markup (nestable, closed with [/b] and [/i]) as SCI's font codes: bold,
1223
+ * italic or both, back to the text's own font with |f|. `plain` is the text without it, for
1224
+ * speech and the script. \[ is a bracket.
1225
+ */
1226
+ function styledText(text: string, fonts: Target["fonts"], where: string): { text: string; plain: string } {
1227
+ if (!text.includes("[")) return { text, plain: text };
1228
+ const fail = (why: string): never => { throw new ContentError(`${where}: ${why}`); };
1229
+ let shown = "", plain = "", bold = 0, italic = 0;
1230
+ const font = () => (bold && italic ? fonts!.boldItalic : bold ? fonts!.bold : italic ? fonts!.italic : 0);
1231
+ for (let i = 0; i < text.length; ) {
1232
+ if (text.startsWith("\\[", i)) {
1233
+ (shown += "["), (plain += "["), (i += 2);
1234
+ continue;
1235
+ }
1236
+ const m = /^\[(\/?)([a-z]+)\]/.exec(text.slice(i));
1237
+ if (text[i] === "[") {
1238
+ if (!m || (m[2] !== "b" && m[2] !== "i")) fail(`unknown markup "${m?.[0] ?? text.slice(i, i + 8)}" (supported: [b] and [i], closed with [/b] and [/i]; \\[ for a bracket)`);
1239
+ if (!fonts) fail("[b] and [i] need the game's bold and italic fonts (\"fonts\" in game.json)");
1240
+ const delta = m![1] ? -1 : 1;
1241
+ if (m![2] === "b") bold += delta;
1242
+ else italic += delta;
1243
+ if (bold < 0 || italic < 0) fail(`${m![0]} without its opening`);
1244
+ const f = font();
1245
+ shown += f ? `|f${f}|` : "|f|";
1246
+ i += m![0].length;
1247
+ continue;
1248
+ }
1249
+ (shown += text[i]), (plain += text[i]), i++;
1250
+ }
1251
+ if (bold || italic) fail(`[${bold ? "b" : "i"}] isn't closed`);
1252
+ return { text: shown.replace(/\|f\d*\|$/, (code) => (code === "|f|" ? "" : code)), plain };
1253
+ }
1254
+
1217
1255
  function plainText(text: string, where: string): string {
1218
1256
  const out = text
1219
1257
  .replace(/[‘’]/g, "'")
@@ -94,4 +94,13 @@ export interface Target {
94
94
  first: number;
95
95
  last: number;
96
96
  };
97
+ /**
98
+ * The game's fonts for Yarn's [b] and [i] markup, which become SCI's font codes (|f3|...|f|)
99
+ * in the message text. Without them, markup is refused.
100
+ */
101
+ fonts?: {
102
+ bold: number;
103
+ italic: number;
104
+ boldItalic: number;
105
+ };
97
106
  }
@@ -70,4 +70,9 @@ export interface Target {
70
70
  * of `script`, each taking the flag number), and the range a mod may use.
71
71
  */
72
72
  flags: { script: number; set: number; clear: number; test: number; first: number; last: number };
73
+ /**
74
+ * The game's fonts for Yarn's [b] and [i] markup, which become SCI's font codes (|f3|...|f|)
75
+ * in the message text. Without them, markup is refused.
76
+ */
77
+ fonts?: { bold: number; italic: number; boldItalic: number };
73
78
  }
@@ -11,6 +11,7 @@ export * from "./gfx/pic-file.ts";
11
11
  export * from "./gfx/quantize.ts";
12
12
  export * from "./gfx/view-file.ts";
13
13
  export * from "./text/font.ts";
14
+ export * from "./text/styled.ts";
14
15
  export * from "./text/message.ts";
15
16
  export * from "./script/kernel-names.ts";
16
17
  export * from "./script/vocab.ts";
@@ -11,6 +11,7 @@ export * from "./gfx/pic-file.js";
11
11
  export * from "./gfx/quantize.js";
12
12
  export * from "./gfx/view-file.js";
13
13
  export * from "./text/font.js";
14
+ export * from "./text/styled.js";
14
15
  export * from "./text/message.js";
15
16
  export * from "./script/kernel-names.js";
16
17
  export * from "./script/vocab.js";
@@ -11,6 +11,7 @@ export * from "./gfx/pic-file.ts";
11
11
  export * from "./gfx/quantize.ts";
12
12
  export * from "./gfx/view-file.ts";
13
13
  export * from "./text/font.ts";
14
+ export * from "./text/styled.ts";
14
15
  export * from "./text/message.ts";
15
16
  export * from "./script/kernel-names.ts";
16
17
  export * from "./script/vocab.ts";
@@ -3,7 +3,13 @@ export interface Glyph {
3
3
  height: number;
4
4
  /** 1 byte per pixel, 1 = ink. */
5
5
  pixels: Uint8Array;
6
+ /** Where the bitmap starts from the pen (default 0); it can be left of it, an italic j's tail. */
7
+ bearingX?: number;
8
+ /** How far the pen moves on (default the width); ink can reach past it. */
9
+ advance?: number;
6
10
  }
11
+ /** How far the pen moves on after a glyph. */
12
+ export declare const advanceOf: (g: Glyph) => number;
7
13
  export interface Font {
8
14
  /** Line height in pixels. */
9
15
  height: number;
@@ -20,6 +26,11 @@ export interface Font {
20
26
  *
21
27
  * Each glyph: u8 width, u8 height, then rows of 1bpp pixels, MSB first,
22
28
  * each row padded to a whole byte.
29
+ *
30
+ * A font can also carry each glyph's bearing and advance (writeFont adds them when any glyph
31
+ * has its own): after the glyphs, i8 bearing and u8 advance per character, then "XMT1". SCI
32
+ * reads glyphs by their offsets and never gets there; without it, bearings are 0 and
33
+ * advances the widths, as fonts always were.
23
34
  */
24
35
  export declare function parseFont(data: Uint8Array): Font;
25
36
  /** A font resource (the inverse of parseFont). Missing glyphs are written as 0×0. */
@@ -33,5 +44,11 @@ export interface TextBitmap {
33
44
  height: number;
34
45
  pixels: Uint8Array;
35
46
  }
36
- /** Renders already-wrapped lines, each aligned within the bitmap width. */
37
- export declare function renderText(font: Font, lines: string[], align?: "left" | "center"): TextBitmap;
47
+ /**
48
+ * Renders already-wrapped lines, each aligned within the line width. Ink that starts left of
49
+ * the pen or runs past the last advance (an italic's first and last letters) is kept: the
50
+ * bitmap grows to hold it, and `originX` says where the pen started.
51
+ */
52
+ export declare function renderText(font: Font, lines: string[], align?: "left" | "center"): TextBitmap & {
53
+ originX: number;
54
+ };