sci2-ts 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.0](https://github.com/rlueder/sci-ts/compare/v0.14.0...v0.15.0) (2026-10-02)
8
+
9
+ ### Added
10
+
11
+ * 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))
12
+
13
+ ## [0.14.0](https://github.com/rlueder/sci-ts/compare/v0.13.0...v0.14.0) (2026-10-02)
14
+
15
+ ### Added
16
+
17
+ * **lib:** figures drawn smaller walk and step slower, and change size in steps ([#16](https://github.com/rlueder/sci-ts/issues/16)) ([06f2b1d](https://github.com/rlueder/sci-ts/commit/06f2b1d91fd95d5b648104b435eb58e3355ba808))
18
+
7
19
  ## [0.13.0](https://github.com/rlueder/sci-ts/compare/v0.12.0...v0.13.0) (2026-10-02)
8
20
 
9
21
  ### Added
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/lib/997.sc CHANGED
@@ -6,6 +6,8 @@
6
6
  ;; Walks its client in a straight line to (x, y), a step (xStep, yStep) every
7
7
  ;; moveSpeed cycles. The interpreter does the stepping (InitBresen, DoBresen) and sends
8
8
  ;; moveDone on arrival; the client then calls motionCue:, which cues the caller.
9
+ ;; A client drawn smaller than full size (farther off) steps less often, in proportion,
10
+ ;; so it doesn't slide across the floor.
9
11
  (class Motion of Obj
10
12
  (properties
11
13
  client 0
@@ -13,6 +15,7 @@
13
15
  x 0 y 0
14
16
  dx 0 dy 0
15
17
  b-moveCnt 0 b-i1 0 b-i2 0 b-di 0 b-xAxis 0 b-incr 0
18
+ progress 0 ; towards the next step, in 128ths of a cycle at full size
16
19
  completed 0)
17
20
 
18
21
  (method (init who toX toY whoCares)
@@ -25,12 +28,21 @@
25
28
  (= x toX)
26
29
  (= y toY)
27
30
  (= b-moveCnt gameTime)
31
+ (= progress 0)
28
32
  (client setHeading: (GetAngle (client x?) (client y?) x y))
29
33
  (InitBresen self))
30
34
 
31
- (method (doit)
32
- (if (>= (- gameTime b-moveCnt) (client moveSpeed?))
33
- (= b-moveCnt gameTime)
35
+ ;; Each cycle counts for the client's size (128 at full size); a step takes moveSpeed
36
+ ;; cycles' worth.
37
+ (method (doit &tmp t e)
38
+ (= t (* (client moveSpeed?) 128))
39
+ ;; (After a long pause, at most 16 cycles count, so the sum stays in 16 bits.)
40
+ (if (> (= e (- gameTime b-moveCnt)) 16) (= e 16))
41
+ (+= progress (* e (if (& (client scaleSignal?) SCALE_ON) (client scaleX?) else 128)))
42
+ (= b-moveCnt gameTime)
43
+ (if (> progress t) (= progress t))
44
+ (if (>= progress t)
45
+ (-= progress t)
34
46
  (DoBresen self)))
35
47
 
36
48
  (method (moveDone)
@@ -139,8 +151,22 @@
139
151
  (if (> c (self lastCel:)) (= c 0))
140
152
  (client cel: c)))
141
153
 
142
- ;; Round and round while the client moves; cel 0 when it stands.
154
+ ;; Round and round while the client moves; cel 0 when it stands. Its legs keep pace with
155
+ ;; its steps: drawn smaller, it turns its cels less often, in proportion.
143
156
  (class Walk of Forward
157
+ (properties
158
+ progress 0)
159
+
160
+ (method (nextCel &tmp t e)
161
+ (= t (* (client cycleSpeed?) 128))
162
+ (if (> (= e (- gameTime cycleCnt)) 16) (= e 16))
163
+ (+= progress (* e (if (& (client scaleSignal?) SCALE_ON) (client scaleX?) else 128)))
164
+ (= cycleCnt gameTime)
165
+ (if (> progress t) (= progress t))
166
+ (if (< progress t) (return (client cel?)))
167
+ (-= progress t)
168
+ (return (+ (client cel?) cycleDir)))
169
+
144
170
  (method (doit)
145
171
  (if (client mover?)
146
172
  (super doit:)
@@ -198,12 +224,15 @@
198
224
  (client cel: c)))))
199
225
 
200
226
  ;; Sizes its client by how far up the screen it stands: frontSize percent at frontY,
201
- ;; backSize at backY, in between in proportion.
227
+ ;; backSize at backY, in between in proportion. The size changes in steps of at least `step`
228
+ ;; percent: a figure redrawn at a new size for every line it moves shimmers.
202
229
  (class Scaler of Obj
203
230
  (properties
204
231
  client 0
205
232
  frontSize 100 backSize 100
206
- frontY 190 backY 0)
233
+ frontY 190 backY 0
234
+ step 2
235
+ shown -1) ; the size the client is drawn at
207
236
 
208
237
  (method (init who fs bs fy by)
209
238
  (= client who)
@@ -220,10 +249,18 @@
220
249
  ((>= y frontY) frontSize)
221
250
  ((<= y backY) backSize)
222
251
  (else (+ backSize (/ (* (- frontSize backSize) (- y backY)) (- frontY backY))))))
223
- (client
224
- scaleX: (/ (* pct 128) 100)
225
- scaleY: (/ (* pct 128) 100)
226
- scaleSignal: (| (client scaleSignal?) SCALE_ON))))
252
+ (self show: pct))
253
+
254
+ ;; Draws the client at pct percent, if that's a step away from its size now.
255
+ (method (show pct &tmp d)
256
+ (= d (- pct shown))
257
+ (if (< d 0) (= d (- d)))
258
+ (if (or (== shown -1) (>= d step))
259
+ (= shown pct)
260
+ (client
261
+ scaleX: (/ (* pct 128) 100)
262
+ scaleY: (/ (* pct 128) 100)
263
+ scaleSignal: (| (client scaleSignal?) SCALE_ON)))))
227
264
 
228
265
  ;; Sizes its client on a floor seen at an angle, where one line across the screen isn't the
229
266
  ;; same depth all the way along. Sizes are measured at the back and front of the floor at
@@ -260,7 +297,4 @@
260
297
  ((< x x3) (+ b (/ (* (- c b) (- x x2)) (- x3 x2))))
261
298
  (else c)))
262
299
  (if (< pct 1) (= pct 1))
263
- (client
264
- scaleX: (/ (* pct 128) 100)
265
- scaleY: (/ (* pct 128) 100)
266
- scaleSignal: (| (client scaleSignal?) SCALE_ON))))
300
+ (self show: pct)))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sci2-ts",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
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
+ };