sci2-ts 0.14.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 +6 -0
- package/lib/991.sc +44 -30
- package/lib/993.sc +40 -6
- package/lib/996.sc +8 -3
- package/package.json +1 -1
- package/packages/content/src/room.js +46 -3
- package/packages/content/src/room.ts +41 -3
- package/packages/content/src/target.d.ts +9 -0
- package/packages/content/src/target.ts +5 -0
- package/packages/sci/src/index.d.ts +1 -0
- package/packages/sci/src/index.js +1 -0
- package/packages/sci/src/index.ts +1 -0
- package/packages/sci/src/text/font.d.ts +19 -2
- package/packages/sci/src/text/font.js +62 -14
- package/packages/sci/src/text/font.ts +64 -15
- package/packages/sci/src/text/styled.d.ts +40 -0
- package/packages/sci/src/text/styled.js +92 -0
- package/packages/sci/src/text/styled.ts +110 -0
- package/packages/sci/src/vm/kernels/text.js +40 -29
- package/packages/sci/src/vm/kernels/text.ts +41 -28
- package/tools/art/build.d.ts +9 -3
- package/tools/art/build.js +33 -2
- package/tools/art/build.ts +27 -4
- package/tools/art/font.d.ts +20 -0
- package/tools/art/font.js +35 -0
- package/tools/art/font.ts +41 -0
- package/tools/game/build.d.ts +3 -1
- package/tools/game/build.js +13 -1
- package/tools/game/build.ts +17 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,12 @@ 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
|
+
|
|
7
13
|
## [0.14.0](https://github.com/rlueder/sci-ts/compare/v0.13.0...v0.14.0) (2026-10-02)
|
|
8
14
|
|
|
9
15
|
### 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
|
|
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
|
-
(
|
|
30
|
-
((
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
(
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
(
|
|
45
|
-
(self addIcon: x
|
|
46
|
-
(
|
|
47
|
-
(self addIcon: x
|
|
48
|
-
(
|
|
49
|
-
(self addIcon: x
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
(=
|
|
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
|
|
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
|
-
(+=
|
|
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
|
|
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
|
@@ -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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
-
/**
|
|
37
|
-
|
|
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
|
+
};
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/** How far the pen moves on after a glyph. */
|
|
2
|
+
export const advanceOf = (g) => g.advance ?? g.width;
|
|
3
|
+
/** The extension at the end of a font resource with bearings and advances: see writeFont. */
|
|
4
|
+
const METRICS_TAG = [0x58, 0x4d, 0x54, 0x31]; // "XMT1"
|
|
5
|
+
const hasOwnMetrics = (g) => !!g && ((g.bearingX ?? 0) !== 0 || (g.advance ?? g.width) !== g.width);
|
|
1
6
|
/**
|
|
2
7
|
* SCI bitmap font (unchanged since SCI0):
|
|
3
8
|
*
|
|
@@ -8,6 +13,11 @@
|
|
|
8
13
|
*
|
|
9
14
|
* Each glyph: u8 width, u8 height, then rows of 1bpp pixels, MSB first,
|
|
10
15
|
* each row padded to a whole byte.
|
|
16
|
+
*
|
|
17
|
+
* A font can also carry each glyph's bearing and advance (writeFont adds them when any glyph
|
|
18
|
+
* has its own): after the glyphs, i8 bearing and u8 advance per character, then "XMT1". SCI
|
|
19
|
+
* reads glyphs by their offsets and never gets there; without it, bearings are 0 and
|
|
20
|
+
* advances the widths, as fonts always were.
|
|
11
21
|
*/
|
|
12
22
|
export function parseFont(data) {
|
|
13
23
|
const v = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
@@ -28,6 +38,19 @@ export function parseFont(data) {
|
|
|
28
38
|
}
|
|
29
39
|
glyphs[c] = { width, height: h, pixels };
|
|
30
40
|
}
|
|
41
|
+
const tagAt = data.length - 4;
|
|
42
|
+
if (tagAt - count * 2 >= 6 + count * 2 && METRICS_TAG.every((b, i) => data[tagAt + i] === b)) {
|
|
43
|
+
const at = tagAt - count * 2;
|
|
44
|
+
glyphs.forEach((g, c) => {
|
|
45
|
+
if (!g)
|
|
46
|
+
return;
|
|
47
|
+
const bearing = (data[at + c * 2] << 24) >> 24, advance = data[at + c * 2 + 1];
|
|
48
|
+
if (bearing)
|
|
49
|
+
g.bearingX = bearing;
|
|
50
|
+
if (advance !== g.width)
|
|
51
|
+
g.advance = advance;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
31
54
|
return { height, glyphs };
|
|
32
55
|
}
|
|
33
56
|
/** A font resource (the inverse of parseFont). Missing glyphs are written as 0×0. */
|
|
@@ -48,7 +71,9 @@ export function writeFont(font) {
|
|
|
48
71
|
}
|
|
49
72
|
return out;
|
|
50
73
|
});
|
|
51
|
-
const
|
|
74
|
+
const metrics = font.glyphs.some(hasOwnMetrics);
|
|
75
|
+
const bodySize = bodies.reduce((n, b) => n + b.length, 0);
|
|
76
|
+
const out = new Uint8Array(6 + count * 2 + bodySize + (metrics ? count * 2 + 4 : 0));
|
|
52
77
|
const v = new DataView(out.buffer);
|
|
53
78
|
v.setUint16(2, count, true);
|
|
54
79
|
v.setUint16(4, font.height, true);
|
|
@@ -60,9 +85,19 @@ export function writeFont(font) {
|
|
|
60
85
|
out.set(b, at);
|
|
61
86
|
at += b.length;
|
|
62
87
|
});
|
|
88
|
+
if (metrics) {
|
|
89
|
+
font.glyphs.forEach((g, c) => {
|
|
90
|
+
const bearing = g?.bearingX ?? 0, advance = g ? advanceOf(g) : 0;
|
|
91
|
+
if (bearing < -128 || bearing > 127 || advance < 0 || advance > 255)
|
|
92
|
+
throw new Error(`glyph ${c}: bearing ${bearing}, advance ${advance}`);
|
|
93
|
+
out[at + c * 2] = bearing & 0xff;
|
|
94
|
+
out[at + c * 2 + 1] = advance;
|
|
95
|
+
});
|
|
96
|
+
out.set(METRICS_TAG, at + count * 2);
|
|
97
|
+
}
|
|
63
98
|
return out;
|
|
64
99
|
}
|
|
65
|
-
export const textWidth = (font, text) => [...text].reduce((w, ch) =>
|
|
100
|
+
export const textWidth = (font, text) => [...text].reduce((w, ch) => { const g = font.glyphs[ch.charCodeAt(0)]; return w + (g ? advanceOf(g) : 0); }, 0);
|
|
66
101
|
/** Splits text into lines no wider than `maxWidth`, breaking at spaces and honouring CR/LF. */
|
|
67
102
|
export function wrapText(font, text, maxWidth) {
|
|
68
103
|
const lines = [];
|
|
@@ -79,25 +114,38 @@ export function wrapText(font, text, maxWidth) {
|
|
|
79
114
|
}
|
|
80
115
|
return lines;
|
|
81
116
|
}
|
|
82
|
-
/**
|
|
117
|
+
/**
|
|
118
|
+
* Renders already-wrapped lines, each aligned within the line width. Ink that starts left of
|
|
119
|
+
* the pen or runs past the last advance (an italic's first and last letters) is kept: the
|
|
120
|
+
* bitmap grows to hold it, and `originX` says where the pen started.
|
|
121
|
+
*/
|
|
83
122
|
export function renderText(font, lines, align = "left") {
|
|
84
|
-
const
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
let x = align === "center" ? Math.floor((width - textWidth(font, line)) / 2) : 0;
|
|
89
|
-
for (const ch of line) {
|
|
123
|
+
const lineWidth = Math.max(1, ...lines.map((l) => textWidth(font, l)));
|
|
124
|
+
const pens = lines.map((line) => {
|
|
125
|
+
let x = align === "center" ? Math.floor((lineWidth - textWidth(font, line)) / 2) : 0;
|
|
126
|
+
return [...line].flatMap((ch) => {
|
|
90
127
|
const g = font.glyphs[ch.charCodeAt(0)];
|
|
91
128
|
if (!g)
|
|
92
|
-
|
|
93
|
-
|
|
129
|
+
return [];
|
|
130
|
+
const at = { g, x: x + (g.bearingX ?? 0) };
|
|
131
|
+
x += advanceOf(g);
|
|
132
|
+
return [at];
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
const inks = pens.flat();
|
|
136
|
+
const left = Math.min(0, ...inks.map((p) => p.x)), right = Math.max(lineWidth, ...inks.map((p) => p.x + p.g.width));
|
|
137
|
+
const width = right - left;
|
|
138
|
+
const height = Math.max(1, lines.length * font.height);
|
|
139
|
+
const pixels = new Uint8Array(width * height);
|
|
140
|
+
pens.forEach((row, r) => {
|
|
141
|
+
for (const { g, x } of row) {
|
|
142
|
+
for (let gy = 0; gy < g.height && r * font.height + gy < height; gy++) {
|
|
94
143
|
for (let gx = 0; gx < g.width; gx++) {
|
|
95
144
|
if (g.pixels[gy * g.width + gx])
|
|
96
|
-
pixels[(
|
|
145
|
+
pixels[(r * font.height + gy) * width + x - left + gx] = 1;
|
|
97
146
|
}
|
|
98
147
|
}
|
|
99
|
-
x += g.width;
|
|
100
148
|
}
|
|
101
149
|
});
|
|
102
|
-
return { width, height, pixels };
|
|
150
|
+
return { width, height, pixels, originX: -left };
|
|
103
151
|
}
|