sci2-ts 0.2.1 → 0.3.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
@@ -1,8 +1,29 @@
1
1
  # Changelog
2
2
 
3
- All notable changes to this project. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
4
- versions follow [Semantic Versioning](https://semver.org/), and the entries are generated
5
- from commit messages ([Conventional Commits](https://www.conventionalcommits.org/)).
3
+ All notable changes to this project. Versions follow [Semantic Versioning](https://semver.org/),
4
+ and the entries are generated from commit messages
5
+ ([Conventional Commits](https://www.conventionalcommits.org/)) when a release is made.
6
+
7
+ ## [0.3.0](https://github.com/rlueder/sci-ts/compare/v0.2.2...v0.3.0) (2026-10-01)
8
+
9
+ ### Added
10
+
11
+ * **content:** close-ups from Yarn ([0546752](https://github.com/rlueder/sci-ts/commit/054675216342747bc5565301aff3d39d27a900c2))
12
+ * **lib:** a text style for every box and menu ([0350a4e](https://github.com/rlueder/sci-ts/commit/0350a4e28a57d951277372ff6144bd8e9e534bee))
13
+ * **lib:** an inventory and close-ups ([6d401d8](https://github.com/rlueder/sci-ts/commit/6d401d8f92d33a4b694dcc617adbb1d5878da246))
14
+ * **lib:** cursors a game can replace, and a wait cursor ([aa55d11](https://github.com/rlueder/sci-ts/commit/aa55d1142568aeb4b14681e44881a4223192d5d2))
15
+ * **lib:** portraits at the top left, with blinking eyes ([8fd6aa1](https://github.com/rlueder/sci-ts/commit/8fd6aa16e83f9608666f67fd998ac852be1a5a99))
16
+ * **tools:** fonts from sheets of glyphs drawn in a paint program ([cb13669](https://github.com/rlueder/sci-ts/commit/cb13669363fb419425b0a54f0a5afb9ca1576dcf))
17
+
18
+ ### Maintenance
19
+
20
+ * release from CI with semantic-release ([1757412](https://github.com/rlueder/sci-ts/commit/17574126c257961dd1058f596c6174445f3d9f16))
21
+
22
+ ## [0.2.2] - 2026-10-01
23
+
24
+ ### Fixed
25
+
26
+ - **tools:** Republish as 0.2.2: 0.2.1 is stuck in npm's staging
6
27
 
7
28
  ## [0.2.1] - 2026-10-01
8
29
 
@@ -68,5 +89,3 @@ from commit messages ([Conventional Commits](https://www.conventionalcommits.org
68
89
 
69
90
  - Workspace, typecheck, tests and CI
70
91
  - Build every game, and publish the site to GitHub Pages by hand
71
-
72
-
package/lib/993.sc ADDED
@@ -0,0 +1,129 @@
1
+ ;;; Things: what the hero carries and the window to choose one from, and close looks at
2
+ ;;; things.
3
+ (script 993)
4
+ (include "system.sh")
5
+
6
+ ;; Something the hero can carry. Its view's loop 0 is its icon in the inventory window and
7
+ ;; loop 1 the cursor while it's being used (the cel's anchor is the hotspot); both anchored
8
+ ;; at the top-left corner. Using it on something sends that thing doVerb: with the item's
9
+ ;; own verb, 10 and up, so the room answers with noun and that verb (in Yarn, a node like
10
+ ;; filings.lens: the game names its items' verbs in items.yaml).
11
+ (class InvItem of Obj
12
+ (properties
13
+ view -1
14
+ verb 0
15
+ description 0) ; what looking at it in the window says
16
+
17
+ ;; Looked at in the inventory window.
18
+ (method (doVerb v)
19
+ (if (and (== v V_LOOK) description) (narrator say: description))))
20
+
21
+ ;; What the hero carries (the `inventory` global): (inventory add: lens), (inventory
22
+ ;; contains: lens). showSelf opens the window: the items in a row, one click picks one to
23
+ ;; use (it becomes the cursor), a right-click on one looks at it, a click elsewhere closes.
24
+ (class Inventory of Set
25
+ (properties
26
+ window 0
27
+ icons 0
28
+ y 24)
29
+
30
+ (method (showSelf &tmp node item icon x)
31
+ (if (not size)
32
+ (narrator say: "You aren't carrying anything.")
33
+ (return))
34
+ (= window
35
+ ((TextItem new:)
36
+ text: ""
37
+ width: (+ (* size (+ INV_ICON INV_GAP)) INV_GAP (* 2 (textStyle margin?)))
38
+ height: (+ INV_ICON (* 2 INV_GAP) (* 2 (textStyle margin?)))
39
+ yourself:))
40
+ (if (!= (textStyle frame?) -1)
41
+ (window
42
+ width: (+ (window width?) (CelWide (textStyle frame?) 0 6) (CelWide (textStyle frame?) 0 7))
43
+ height: (+ (window height?) (CelHigh (textStyle frame?) 0 4) (CelHigh (textStyle frame?) 0 5))))
44
+ (window x: (/ (- SCREEN_WIDTH (window width?)) 2) y: y init:)
45
+ (= icons (List new:))
46
+ (= x (+ (window x?) (window textLeft?) INV_GAP))
47
+ (for ((= node (FirstNode elements))) node ((= node (NextNode node)))
48
+ (= item (NodeValue node))
49
+ ;; The icon keeps its item in `actions`.
50
+ (= icon ((View new:) view: (item view?) loop: 0 cel: 0 actions: item x: x y: (+ (window y?) (window textTop?) INV_GAP) yourself:))
51
+ (icon init:)
52
+ (icon plane: uiPlane setPri: (+ (window priority?) 1))
53
+ (icons add: icon)
54
+ (+= x (+ INV_ICON INV_GAP)))
55
+ (= dialog self))
56
+
57
+ ;; The item whose icon is at (x, y), or 0.
58
+ (method (itemAt ex ey &tmp icon)
59
+ (= icon (icons firstTrue: #onMe ex ey))
60
+ (return (if icon (icon actions?) else 0)))
61
+
62
+ (method (handleEvent event &tmp item)
63
+ (event claimed: TRUE)
64
+ (if (!= (event type?) EV_MOUSE_DOWN) (return TRUE))
65
+ (= item (self itemAt: (event x?) (event y?)))
66
+ (cond
67
+ ((not item) (self hide:))
68
+ ((& (event modifiers?) MOD_RIGHT) (item doVerb: V_LOOK))
69
+ (else
70
+ (self hide:)
71
+ (user useItem: item)))
72
+ (return TRUE))
73
+
74
+ (method (hide)
75
+ (if (== dialog self) (= dialog 0))
76
+ (if icons
77
+ (icons eachElementDo: #dispose)
78
+ (icons dispose:)
79
+ (= icons 0))
80
+ (if window (window dispose:) (= window 0)))
81
+
82
+ (method (delete item)
83
+ (if (== theItem item) (user useItem: 0))
84
+ (super delete: item &rest)))
85
+
86
+ ;; A close look at something: a view shown in the middle of the screen with the room dimmed
87
+ ;; behind it, until a click; then it goes, and whoever asked is cued.
88
+ ;; ((CloseUp new:) show: view [loop [cel [whoCares]]])
89
+ ;; Its cels are anchored at their top-left corner. The dimming is the library's view 996,
90
+ ;; drawn in remap colour 253 (which the art palette keeps free) at `dim` percent.
91
+ (class CloseUp of Obj
92
+ (properties
93
+ shade 0
94
+ picture 0
95
+ caller 0
96
+ dim 50)
97
+
98
+ (method (show v l c whoCares &tmp lp cl)
99
+ (= lp (if (> argc 1) l else 0))
100
+ (= cl (if (> argc 2) c else 0))
101
+ (= caller (if (> argc 3) whoCares else 0))
102
+ (RemapColors REMAP_BY_PERCENT 253 dim)
103
+ (= shade ((View new:) view: CLOSE_UP_SHADE x: 0 y: 0 yourself:))
104
+ (shade init:)
105
+ (shade plane: uiPlane setPri: 10)
106
+ (= picture
107
+ ((View new:)
108
+ view: v loop: lp cel: cl
109
+ x: (/ (- SCREEN_WIDTH (CelWide v lp cl)) 2)
110
+ y: (/ (- SCREEN_HEIGHT (CelHigh v lp cl)) 2)
111
+ yourself:))
112
+ (picture init:)
113
+ (picture plane: uiPlane setPri: 20)
114
+ (= dialog self))
115
+
116
+ (method (handleEvent event)
117
+ (event claimed: TRUE)
118
+ (if (== (event type?) EV_MOUSE_DOWN)
119
+ (self close:))
120
+ (return TRUE))
121
+
122
+ (method (close &tmp c)
123
+ (if (== dialog self) (= dialog 0))
124
+ (if picture (picture dispose:) (= picture 0))
125
+ (if shade (shade dispose:) (= shade 0))
126
+ (RemapColors REMAP_OFF 253)
127
+ (= c caller)
128
+ (self dispose:)
129
+ (if c (c cue:))))
package/lib/995.sc CHANGED
@@ -15,22 +15,69 @@
15
15
  ;; a click walks the hero there, or does the verb to what's under the cursor: an actor or
16
16
  ;; prop, a feature, or else the room. A click while a line is shown dismisses it; while a
17
17
  ;; menu is open, it goes to the menu.
18
+ ;;
19
+ ;; The cursor is a view for each verb, and another while the player can't act (handsOff).
20
+ ;; A game with its own sets them in its init: (user walkCursor: 261 waitCursor: 265 ...).
21
+ ;; I or Tab opens the inventory; an item picked there is used like a verb (V_ITEM), with
22
+ ;; its own cursor, until right-click goes back to walking.
18
23
  (class User of Obj
19
24
  (properties
20
25
  verb V_WALK
21
- canInput TRUE)
26
+ canInput TRUE
27
+ lookCursor 991 ; the library's: CURSOR_BASE + verb
28
+ talkCursor 992
29
+ walkCursor 993
30
+ doCursor 994
31
+ waitCursor 995 ; CURSOR_WAIT
32
+ cursor -1 ; the view and loop showing now
33
+ cursorLoop 0)
22
34
 
23
35
  (method (init)
24
36
  (self setVerb: verb))
25
37
 
26
38
  (method (setVerb v)
27
39
  (= verb v)
28
- (SetCursor (+ CURSOR_BASE v) 0 0))
40
+ (self showCursor:))
41
+
42
+ ;; Picks an item to use (0: none).
43
+ (method (useItem item)
44
+ (= theItem item)
45
+ (cond
46
+ (item (self setVerb: V_ITEM))
47
+ ((== verb V_ITEM) (self setVerb: V_WALK))))
48
+
49
+ ;; The cursor for a verb (0: waiting).
50
+ (method (cursorFor v)
51
+ (switch v
52
+ (V_LOOK (return lookCursor))
53
+ (V_TALK (return talkCursor))
54
+ (V_WALK (return walkCursor))
55
+ (V_DO (return doCursor))
56
+ (else (return waitCursor))))
57
+
58
+ (method (showCursor &tmp v l)
59
+ (= l 0)
60
+ (cond
61
+ ((not canInput) (= v waitCursor))
62
+ ((and (== verb V_ITEM) theItem) (= v (theItem view?)) (= l 1))
63
+ (else (= v (self cursorFor: verb))))
64
+ (if (or (!= v cursor) (!= l cursorLoop))
65
+ (= cursor v)
66
+ (= cursorLoop l)
67
+ (SetCursor v l 0)))
29
68
 
30
69
  (method (doit)
70
+ (self showCursor:)
31
71
  (while (GetEvent EV_ALL theEvent)
32
72
  (self handleEvent: theEvent)))
33
73
 
74
+ ;; The verb right-click goes to: walk, do, look, talk, the item picked (if any), walk.
75
+ (method (nextVerb)
76
+ (cond
77
+ ((== verb V_ITEM) (return V_WALK))
78
+ ((and (== verb V_TALK) theItem) (return V_ITEM))
79
+ (else (return (+ (mod verb VERB_COUNT) 1)))))
80
+
34
81
  (method (handleEvent event &tmp obj ex ey)
35
82
  (event claimed: FALSE)
36
83
  ;; A line being shown, then an open menu, get clicks first.
@@ -38,9 +85,15 @@
38
85
  (if dialog
39
86
  (dialog handleEvent: event)
40
87
  (return))
41
- (if (or (not canInput) (!= (event type?) EV_MOUSE_DOWN)) (return))
88
+ (if (not canInput) (return))
89
+ (if (and (== (event type?) EV_KEY_DOWN)
90
+ (or (== (event message?) KEY_TAB) (== (event message?) KEY_i) (== (event message?) KEY_I)))
91
+ (event claimed: TRUE)
92
+ (inventory showSelf:)
93
+ (return))
94
+ (if (!= (event type?) EV_MOUSE_DOWN) (return))
42
95
  (if (& (event modifiers?) MOD_RIGHT)
43
- (self setVerb: (+ (mod verb VERB_COUNT) 1))
96
+ (self setVerb: (self nextVerb:))
44
97
  (return))
45
98
  (= ex (event x?))
46
99
  (= ey (event y?))
@@ -50,4 +103,4 @@
50
103
  (= obj (cast firstTrue: #onMe ex ey))
51
104
  (if (not obj) (= obj (features firstTrue: #onMe ex ey)))
52
105
  (if (not obj) (= obj curRoom))
53
- (if obj (obj doVerb: verb))))
106
+ (if obj (obj doVerb: (if (== verb V_ITEM) (theItem verb?) else verb)))))
package/lib/996.sc CHANGED
@@ -3,8 +3,25 @@
3
3
  (script 996)
4
4
  (include "system.sh")
5
5
 
6
+ ;; How text boxes and menus look, for the whole game (the `textStyle` global): the font, the
7
+ ;; ink, paper and border colours, and a frame drawn around each box instead of the border.
8
+ ;; The game sets it in its init, after (super init:):
9
+ ;; (textStyle font: 1 fore: 3 back: 12 frame: 260)
10
+ ;; A frame is a view whose loop 0 has eight cels, each anchored at its top-left corner: the
11
+ ;; corners (top-left, top-right, bottom-left, bottom-right), then the edges (top, bottom, left,
12
+ ;; right). The edges are tiled between the corners; transparent pixels show the paper.
13
+ (class TextStyle of Obj
14
+ (properties
15
+ font 0
16
+ fore 0 ; ink
17
+ back 255 ; paper
18
+ border 0 ; a one-pixel border's colour; -1: none
19
+ frame -1 ; a frame view, or -1
20
+ margin 4)) ; between the text and the border or frame
21
+
6
22
  ;; A box of text over the room: drawn by the interpreter into a bitmap, shown as a screen
7
- ;; item in the UI plane. Set text (and font, colours, width, x, y), then init.
23
+ ;; item in the UI plane. Set text (and width, x, y), then init; it's as tall as the text
24
+ ;; unless height is set. Font, colours and frame come from the textStyle unless set here.
8
25
  (class TextItem of Obj
9
26
  (properties
10
27
  x 0 y 0 z 0
@@ -14,36 +31,66 @@
14
31
  bitmap 0
15
32
  scaleSignal 0 scaleX 128 scaleY 128
16
33
  text 0
17
- font 0
18
- fore 0
19
- back 255
34
+ font -1 ; -1 here and below: the textStyle's
35
+ fore -1
36
+ back -1
20
37
  skip 254
21
38
  mode 0 ; 0 left, 1 centred
22
- borderColor 0
39
+ borderColor -2 ; -1: none
40
+ frame -2 ; -1: none
23
41
  textLeft 0 textTop 0 textRight -1 textBottom -1
24
42
  width 200
25
43
  height 0)
26
44
 
27
- (method (init &tmp r)
28
- ;; As tall as the text needs, with a 4-pixel margin inside the border.
29
- (= r (Array ARRAY_NEW 4 0))
30
- (TextSize r text font (- width 8))
31
- (= height (+ (Array ARRAY_AT r 3) 9))
32
- (Array ARRAY_FREE r)
33
- (= textLeft 4)
34
- (= textTop 4)
35
- (= textRight (- width 5))
36
- (= textBottom (- height 5))
45
+ (method (init &tmp r m padL padT padR padB)
46
+ (if (== font -1) (= font (textStyle font?)))
47
+ (if (== fore -1) (= fore (textStyle fore?)))
48
+ (if (== back -1) (= back (textStyle back?)))
49
+ (if (== frame -2) (= frame (textStyle frame?)))
50
+ (if (== borderColor -2) (= borderColor (if (!= frame -1) -1 else (textStyle border?))))
51
+ ;; As tall as the text needs, inside the margin and the frame.
52
+ (= m (textStyle margin?))
53
+ (= padL m) (= padT m) (= padR m) (= padB m)
54
+ (if (!= frame -1)
55
+ (+= padL (CelWide frame 0 6))
56
+ (+= padR (CelWide frame 0 7))
57
+ (+= padT (CelHigh frame 0 4))
58
+ (+= padB (CelHigh frame 0 5)))
59
+ (if (not height)
60
+ (= r (Array ARRAY_NEW 4 0))
61
+ (TextSize r text font (- width (+ padL padR)))
62
+ (= height (+ (Array ARRAY_AT r 3) 1 padT padB))
63
+ (Array ARRAY_FREE r))
64
+ (= textLeft padL)
65
+ (= textTop padT)
66
+ (= textRight (- width (+ padR 1)))
67
+ (= textBottom (- height (+ padB 1)))
37
68
  (= plane uiPlane)
38
69
  (= bitmap (CreateTextBitmap 0 width height self))
70
+ (if (!= frame -1) (self drawFrame:))
39
71
  (AddScreenItem self))
40
72
 
73
+ ;; The edges, tiled, then the corners over their ends.
74
+ (method (drawFrame &tmp i step)
75
+ (if (< (= step (CelWide frame 0 4)) 1) (= step 1))
76
+ (for ((= i (CelWide frame 0 0))) (< i width) ((+= i step))
77
+ (Bitmap BITMAP_DRAW_VIEW bitmap frame 0 4 i 0)
78
+ (Bitmap BITMAP_DRAW_VIEW bitmap frame 0 5 i (- height (CelHigh frame 0 5))))
79
+ (if (< (= step (CelHigh frame 0 6)) 1) (= step 1))
80
+ (for ((= i (CelHigh frame 0 0))) (< i height) ((+= i step))
81
+ (Bitmap BITMAP_DRAW_VIEW bitmap frame 0 6 0 i)
82
+ (Bitmap BITMAP_DRAW_VIEW bitmap frame 0 7 (- width (CelWide frame 0 7)) i))
83
+ (Bitmap BITMAP_DRAW_VIEW bitmap frame 0 0 0 0)
84
+ (Bitmap BITMAP_DRAW_VIEW bitmap frame 0 1 (- width (CelWide frame 0 1)) 0)
85
+ (Bitmap BITMAP_DRAW_VIEW bitmap frame 0 2 0 (- height (CelHigh frame 0 2)))
86
+ (Bitmap BITMAP_DRAW_VIEW bitmap frame 0 3 (- width (CelWide frame 0 3)) (- height (CelHigh frame 0 3))))
87
+
41
88
  (method (onMe theX theY)
42
89
  (return (and (>= theX x) (< theX (+ x width)) (>= theY y) (< theY (+ y height)))))
43
90
 
44
91
  (method (dispose)
45
92
  (DeleteScreenItem self)
46
- (if bitmap (Bitmap 1 bitmap) (= bitmap 0))
93
+ (if bitmap (Bitmap BITMAP_DISPOSE bitmap) (= bitmap 0))
47
94
  (super dispose:)))
48
95
 
49
96
  ;; Shows a line until it's been there long enough to read, or the player clicks; then
@@ -53,7 +100,7 @@
53
100
  caller 0
54
101
  box 0
55
102
  until 0 ; the game time it goes
56
- font 0
103
+ font -1 ; -1: the textStyle's
57
104
  x -1 ; -1: centred
58
105
  y 16
59
106
  width 220)
@@ -106,14 +153,16 @@
106
153
  (= line (String 11 "%s: %s" name txt))
107
154
  (super say: line &rest whoCares)))
108
155
 
109
- ;; Someone with a portrait: a view whose loop 0 is the bust, loop 1 the mouth (moving while
110
- ;; they talk) and loop 2 the eyes, every cel the same size and placed at one point (the
111
- ;; bottom middle). The room's talker hands its parts to init: mouth bust eyes frame, where
112
- ;; frame is drawn first (the bust). The text goes beside the portrait.
156
+ ;; Someone with a portrait: a view whose loop 0 is the bust, loop 1 the mouth and loop 2 the
157
+ ;; eyes, every cel the same size and anchored at its top-left corner. The room's talker hands
158
+ ;; its parts to init: mouth bust eyes frame, where frame is drawn first (the bust), placed
159
+ ;; where the portrait goes (the top left of the screen). The text goes to its right. While
160
+ ;; the line is said the mouth moves, then closes; the eyes blink.
113
161
  (class PortraitTalker of Talker
114
162
  (properties
115
163
  mouth 0 bust 0 eyes 0 frame 0
116
- priority 150)
164
+ priority 150
165
+ mouthUntil 0) ; the game time the mouth stops
117
166
 
118
167
  (method (init theMouth theBust theEyes theFrame)
119
168
  (= mouth theMouth)
@@ -125,15 +174,24 @@
125
174
  (self init:)
126
175
  (= portrait (if frame frame else bust))
127
176
  (if portrait
128
- (= x (+ (portrait x?) (/ (CelWide (portrait view?) 0 0) 2) 6))
129
- (= y (+ (- (portrait y?) (CelHigh (portrait view?) 0 0)) 1))
130
- (= width (- (- SCREEN_WIDTH x) 8)))
177
+ (= x (+ (portrait x?) (CelWide (portrait view?) (portrait loop?) (portrait cel?)) 6))
178
+ (= y (portrait y?))
179
+ (= width (- SCREEN_WIDTH (+ x 6))))
131
180
  (super say: txt &rest whoCares)
132
181
  (self showPart: frame priority)
133
182
  (self showPart: bust priority)
134
183
  (self showPart: eyes (+ priority 1))
135
184
  (self showPart: mouth (+ priority 1))
136
- (if mouth (mouth setCycle: Forward)))
185
+ (if eyes (eyes setCycle: Blink))
186
+ (if mouth
187
+ (mouth setCycle: Forward)
188
+ ;; About as long as it takes to say: half a second, and two cycles a letter.
189
+ (= mouthUntil (+ gameTime 30 (* 2 (String STRING_LENGTH txt))))))
190
+
191
+ (method (doit)
192
+ (if (and mouth (mouth cycler?) (>= gameTime mouthUntil))
193
+ (mouth setCycle: 0 setCel: 0))
194
+ (super doit:))
137
195
 
138
196
  (method (showPart part pri)
139
197
  (if part
package/lib/997.sc CHANGED
@@ -169,6 +169,32 @@
169
169
  else
170
170
  (client cel: c))))
171
171
 
172
+ ;; Eyes: cel 0 (open) most of the time; every two or three seconds, the other cels in turn
173
+ ;; (shut) and back to 0.
174
+ (class Blink of Cycle
175
+ (properties
176
+ nextBlink 0)
177
+
178
+ (method (init who)
179
+ (super init: who)
180
+ (self rest:))
181
+
182
+ (method (rest)
183
+ (= nextBlink (+ gameTime 90 (Random 0 90))))
184
+
185
+ (method (doit &tmp c)
186
+ (if (== (client cel?) 0)
187
+ (if (and (>= gameTime nextBlink) (> (self lastCel:) 0))
188
+ (= cycleCnt gameTime)
189
+ (client cel: 1))
190
+ else
191
+ (= c (self nextCel:))
192
+ (if (> c (self lastCel:))
193
+ (client cel: 0)
194
+ (self rest:)
195
+ else
196
+ (client cel: c)))))
197
+
172
198
  ;; Sizes its client by how far up the screen it stands: frontSize percent at frontY,
173
199
  ;; backSize at backY, in between in proportion.
174
200
  (class Scaler of Obj
package/lib/998.sc CHANGED
@@ -30,6 +30,8 @@
30
30
  (= theEvent (Event new:))
31
31
  (= messager (Messager new:))
32
32
  (= narrator (Narrator new:))
33
+ (= textStyle (TextStyle new:))
34
+ (= inventory (Inventory new:))
33
35
  (= music (Sound new:))
34
36
  (= sfx (Sound new:))
35
37
  (= uiPlane ((Plane new:) priority: 200 picture: -2 yourself:))
@@ -77,10 +79,10 @@
77
79
 
78
80
  ;; Clicks do nothing (but dismiss text) until handsOn: for cutscenes.
79
81
  (method (handsOff)
80
- (user canInput: FALSE))
82
+ (user canInput: FALSE showCursor:))
81
83
 
82
84
  (method (handsOn)
83
- (user canInput: TRUE)))
85
+ (user canInput: TRUE showCursor:)))
84
86
 
85
87
  ;; A room: its picture, where you can walk, its exits, and what's said about it.
86
88
  (class Room of Obj
package/lib/999.sc CHANGED
@@ -27,7 +27,10 @@
27
27
  uiPlane ; the plane over the room, for text
28
28
  gameTime ; cycles since the game started
29
29
  quit ; set to end the game
30
- [gameFlags 64]) ; story flags, a bit each
30
+ [gameFlags 64] ; story flags, a bit each
31
+ textStyle ; how text boxes and menus look (a TextStyle)
32
+ inventory ; what the hero carries (an Inventory)
33
+ theItem) ; the item picked to use, or 0
31
34
 
32
35
  (procedure (SetFlag n)
33
36
  (|= [gameFlags (/ n 16)] (<< 1 (mod n 16))))
package/lib/resources.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { Colour, ResourceType, writeView } from "../tools/game/kit.js";
2
2
  /**
3
- * The class library's own resources: a cursor for each verb, view CURSOR_BASE + verb
4
- * (system.sh). X is black, W white, . transparent.
3
+ * The class library's own resources: a cursor for each verb, view CURSOR_BASE + verb, and
4
+ * one while the player waits, CURSOR_WAIT (system.sh). X is black, W white, . transparent.
5
5
  */
6
6
  const CURSOR_BASE = 990;
7
+ const CURSOR_WAIT = 995;
7
8
  const CURSORS = {
8
9
  // V_LOOK: an eye.
9
10
  1: {
@@ -70,6 +71,22 @@ const CURSORS = {
70
71
  "...XXXXXX..",
71
72
  ],
72
73
  },
74
+ // Waiting: an hourglass.
75
+ [CURSOR_WAIT - CURSOR_BASE]: {
76
+ hotspot: "centre",
77
+ art: [
78
+ "XXXXXXXXX",
79
+ "XWWWWWWWX",
80
+ ".XWWWWWX.",
81
+ "..XWWWX..",
82
+ "...XWX...",
83
+ "...XWX...",
84
+ "..XWWWX..",
85
+ ".XWWWWWX.",
86
+ "XWWWWWWWX",
87
+ "XXXXXXXXX",
88
+ ],
89
+ },
73
90
  };
74
91
  function cursor(art, hotspot) {
75
92
  const width = art[0].length, height = art.length;
@@ -79,6 +96,19 @@ function cursor(art, hotspot) {
79
96
  const cel = { width, height, displaceX: (width >> 1) - hx, displaceY: height - 1 - hy, skipColor: Colour.Transparent, pixels };
80
97
  return writeView({ flags: 1, loops: [{ link: -1, mirror: false, cels: [cel] }], palette: undefined });
81
98
  }
99
+ /**
100
+ * What a close-up dims the room with (CLOSE_UP_SHADE): the whole screen in colour 253, which
101
+ * CloseUp makes a remap colour that darkens what's under it.
102
+ */
103
+ const CLOSE_UP_SHADE = 996;
104
+ function shade() {
105
+ const [width, height] = [320, 200];
106
+ const cel = { width, height, displaceX: width >> 1, displaceY: height - 1, skipColor: Colour.Transparent, pixels: new Uint8Array(width * height).fill(253) };
107
+ return writeView({ flags: 1, loops: [{ link: -1, mirror: false, cels: [cel] }], palette: undefined });
108
+ }
82
109
  export default function resources() {
83
- return Object.entries(CURSORS).map(([verb, c]) => ({ type: ResourceType.View, number: CURSOR_BASE + Number(verb), data: cursor(c.art, c.hotspot) }));
110
+ return [
111
+ ...Object.entries(CURSORS).map(([verb, c]) => ({ type: ResourceType.View, number: CURSOR_BASE + Number(verb), data: cursor(c.art, c.hotspot) })),
112
+ { type: ResourceType.View, number: CLOSE_UP_SHADE, data: shade() },
113
+ ];
84
114
  }
package/lib/resources.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import { Colour, ResourceType, writeView, type ResourceData } from "../tools/game/kit.ts";
2
2
 
3
3
  /**
4
- * The class library's own resources: a cursor for each verb, view CURSOR_BASE + verb
5
- * (system.sh). X is black, W white, . transparent.
4
+ * The class library's own resources: a cursor for each verb, view CURSOR_BASE + verb, and
5
+ * one while the player waits, CURSOR_WAIT (system.sh). X is black, W white, . transparent.
6
6
  */
7
7
  const CURSOR_BASE = 990;
8
+ const CURSOR_WAIT = 995;
8
9
 
9
10
  const CURSORS: Record<number, { art: string[]; hotspot: "tip" | "centre" }> = {
10
11
  // V_LOOK: an eye.
@@ -72,6 +73,22 @@ const CURSORS: Record<number, { art: string[]; hotspot: "tip" | "centre" }> = {
72
73
  "...XXXXXX..",
73
74
  ],
74
75
  },
76
+ // Waiting: an hourglass.
77
+ [CURSOR_WAIT - CURSOR_BASE]: {
78
+ hotspot: "centre",
79
+ art: [
80
+ "XXXXXXXXX",
81
+ "XWWWWWWWX",
82
+ ".XWWWWWX.",
83
+ "..XWWWX..",
84
+ "...XWX...",
85
+ "...XWX...",
86
+ "..XWWWX..",
87
+ ".XWWWWWX.",
88
+ "XWWWWWWWX",
89
+ "XXXXXXXXX",
90
+ ],
91
+ },
75
92
  };
76
93
 
77
94
  function cursor(art: string[], hotspot: "tip" | "centre") {
@@ -83,6 +100,20 @@ function cursor(art: string[], hotspot: "tip" | "centre") {
83
100
  return writeView({ flags: 1, loops: [{ link: -1, mirror: false, cels: [cel] }], palette: undefined });
84
101
  }
85
102
 
103
+ /**
104
+ * What a close-up dims the room with (CLOSE_UP_SHADE): the whole screen in colour 253, which
105
+ * CloseUp makes a remap colour that darkens what's under it.
106
+ */
107
+ const CLOSE_UP_SHADE = 996;
108
+ function shade() {
109
+ const [width, height] = [320, 200];
110
+ const cel = { width, height, displaceX: width >> 1, displaceY: height - 1, skipColor: Colour.Transparent, pixels: new Uint8Array(width * height).fill(253) };
111
+ return writeView({ flags: 1, loops: [{ link: -1, mirror: false, cels: [cel] }], palette: undefined });
112
+ }
113
+
86
114
  export default function resources(): ResourceData[] {
87
- return Object.entries(CURSORS).map(([verb, c]) => ({ type: ResourceType.View, number: CURSOR_BASE + Number(verb), data: cursor(c.art, c.hotspot) }));
115
+ return [
116
+ ...Object.entries(CURSORS).map(([verb, c]) => ({ type: ResourceType.View, number: CURSOR_BASE + Number(verb), data: cursor(c.art, c.hotspot) })),
117
+ { type: ResourceType.View, number: CLOSE_UP_SHADE, data: shade() },
118
+ ];
88
119
  }
package/lib/system.sh CHANGED
@@ -8,15 +8,32 @@
8
8
  V_WALK
9
9
  V_DO)
10
10
  (define VERB_COUNT 4)
11
+ (define V_ITEM 5) ; using the item picked in the inventory (theItem)
11
12
 
12
- ;; Cursors: one view per verb, CURSOR_BASE + verb.
13
+ ;; Cursors: the library's are views CURSOR_BASE + verb, and CURSOR_WAIT while the player
14
+ ;; can't act (a game can bring its own: User).
13
15
  (define CURSOR_BASE 990)
16
+ (define CURSOR_WAIT 995)
17
+
18
+ ;; The inventory window: icons are INV_ICON pixels square, INV_GAP apart.
19
+ (define INV_ICON 24)
20
+ (define INV_GAP 4)
21
+
22
+ ;; Close-ups: the room is dimmed by this view, drawn in a remap colour.
23
+ (define CLOSE_UP_SHADE 996)
24
+ (define REMAP_OFF 0)
25
+ (define REMAP_BY_PERCENT 2)
14
26
 
15
27
  ;; Event types, as GetEvent reports them.
16
28
  (define EV_NULL 0)
17
29
  (define EV_MOUSE_DOWN 1)
18
30
  (define EV_MOUSE_UP 2)
19
31
  (define EV_KEY_DOWN 4)
32
+
33
+ ;; Keys (an event's message).
34
+ (define KEY_TAB 9)
35
+ (define KEY_I 73)
36
+ (define KEY_i 105)
20
37
  (define EV_ALL $7fff)
21
38
  ;; The right button arrives as a click with shift held.
22
39
  (define MOD_RIGHT 3)
@@ -37,6 +54,10 @@
37
54
  (define ARRAY_FREE 4)
38
55
  (define STRING_LENGTH 10)
39
56
 
57
+ ;; Bitmap subfunctions.
58
+ (define BITMAP_DISPOSE 1)
59
+ (define BITMAP_DRAW_VIEW 3)
60
+
40
61
  ;; DoSound kernel calls.
41
62
  (define SND_INIT 6)
42
63
  (define SND_DISPOSE 7)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sci2-ts",
3
- "version": "0.2.1",
3
+ "version": "0.3.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",
@@ -537,11 +537,23 @@ class RoomCompiler {
537
537
  ? [{ op: "async", code: send({ global: g.sound }, [["number", [n]], ["loop", [1]], ["play", ["self"]]]) }]
538
538
  : [{ op: "code", code: send({ global: g.sound }, [["number", [n]], ["loop", [1]], ["play", []]]) }];
539
539
  }
540
+ case "closeup": {
541
+ if (args.length < 1 || args.length > 3)
542
+ fail("expected <<closeup view [loop [cel]]>>");
543
+ if (!this.target.closeUp)
544
+ fail("this game has no close-ups");
545
+ const [view, loop = "0", cel = "0"] = args;
546
+ // A new close-up, shown until the player clicks; then it cues the script.
547
+ return [{ op: "async", code: [
548
+ ...send({ cls: this.target.closeUp }, [["new", []]]),
549
+ "pushi #show", "pushi 4", ...pushVal(int(view, "view", 0, 65535)), ...pushVal(int(loop, "loop", 0, 255)), ...pushVal(int(cel, "cel", 0, 255)), "pushSelf", "send 12",
550
+ ] }];
551
+ }
540
552
  case "room":
541
553
  arity(1, "number");
542
554
  return [{ op: "code", code: send({ global: g.curRoom }, [["newRoom", [int(args[0], "room", 0, 65535)]]]) }];
543
555
  default:
544
- return fail("unknown command (known: walk, face, wait, hide, show, loop, cel, animate, stop, music, sound, room; and set, if)");
556
+ return fail("unknown command (known: walk, face, wait, hide, show, loop, cel, animate, stop, music, sound, closeup, room; and set, if)");
545
557
  }
546
558
  }
547
559
  /** Loop music n, unless it's what's playing already. */
@@ -636,11 +636,21 @@ class RoomCompiler {
636
636
  ? [{ op: "async", code: send({ global: g.sound }, [["number", [n]], ["loop", [1]], ["play", ["self"]]]) }]
637
637
  : [{ op: "code", code: send({ global: g.sound }, [["number", [n]], ["loop", [1]], ["play", []]]) }];
638
638
  }
639
+ case "closeup": {
640
+ if (args.length < 1 || args.length > 3) fail("expected <<closeup view [loop [cel]]>>");
641
+ if (!this.target.closeUp) fail("this game has no close-ups");
642
+ const [view, loop = "0", cel = "0"] = args;
643
+ // A new close-up, shown until the player clicks; then it cues the script.
644
+ return [{ op: "async", code: [
645
+ ...send({ cls: this.target.closeUp! }, [["new", []]]),
646
+ "pushi #show", "pushi 4", ...pushVal(int(view, "view", 0, 65535)), ...pushVal(int(loop, "loop", 0, 255)), ...pushVal(int(cel, "cel", 0, 255)), "pushSelf", "send 12",
647
+ ] }];
648
+ }
639
649
  case "room":
640
650
  arity(1, "number");
641
651
  return [{ op: "code", code: send({ global: g.curRoom }, [["newRoom", [int(args[0], "room", 0, 65535)]]]) }];
642
652
  default:
643
- return fail("unknown command (known: walk, face, wait, hide, show, loop, cel, animate, stop, music, sound, room; and set, if)");
653
+ return fail("unknown command (known: walk, face, wait, hide, show, loop, cel, animate, stop, music, sound, closeup, room; and set, if)");
644
654
  }
645
655
  }
646
656
 
@@ -59,6 +59,11 @@ export interface Target {
59
59
  };
60
60
  /** The class that cycles a prop's cels forever (<<animate x forever>>, cycle: forward). */
61
61
  forwardCycle: string;
62
+ /**
63
+ * Close-ups (<<closeup view [loop [cel]]>>), if the target has them: a class whose new
64
+ * instance takes `show: view loop cel caller` and cues the caller when it's dismissed.
65
+ */
66
+ closeUp?: string;
62
67
  /** Verb number for "said on arrival" messages. */
63
68
  narrationVerb: number;
64
69
  /** Verb names as used in Yarn node titles (lower case) -> verb numbers. */
@@ -46,6 +46,11 @@ export interface Target {
46
46
  teller: { class: string; props: Record<string, number>; verb: number };
47
47
  /** The class that cycles a prop's cels forever (<<animate x forever>>, cycle: forward). */
48
48
  forwardCycle: string;
49
+ /**
50
+ * Close-ups (<<closeup view [loop [cel]]>>), if the target has them: a class whose new
51
+ * instance takes `show: view loop cel caller` and cues the caller when it's dismissed.
52
+ */
53
+ closeUp?: string;
49
54
  /** Verb number for "said on arrival" messages. */
50
55
  narrationVerb: number;
51
56
  /** Verb names as used in Yarn node titles (lower case) -> verb numbers. */
@@ -330,9 +330,34 @@ export const textKernels = {
330
330
  g.items.delete(obj);
331
331
  return text !== original ? 1 : 0;
332
332
  },
333
- Bitmap: (vm, [subop = 0, ref = 0]) => {
334
- if (subop === 1 && vm.memory.bitmap(ref))
333
+ // Bitmap(1, bitmap): dispose. Bitmap(3, bitmap, view, loop, cel, x, y): draw a view's cel
334
+ // into the bitmap with its origin at (x, y), skipping its transparent pixels (SCI2.1's
335
+ // kBitmapDrawView; the class library frames text boxes with it).
336
+ Bitmap: (vm, [subop = 0, ref = 0, ...args]) => {
337
+ const bmp = vm.memory.bitmap(ref);
338
+ if (subop === 1 && bmp)
335
339
  vm.memory.free(segmentOf(ref));
340
+ if (subop === 3 && bmp) {
341
+ const [view = 0, loop = 0, celNo = 0, x = 0, y = 0] = args.map(toSigned);
342
+ const found = graphics(vm).cel(view, loop, celNo);
343
+ if (!found)
344
+ return 0;
345
+ const { cel, mirror } = found;
346
+ const left = x - ((cel.width >> 1) - cel.displaceX), top = y - (cel.height - 1 - cel.displaceY);
347
+ for (let cy = 0; cy < cel.height; cy++) {
348
+ const by = top + cy;
349
+ if (by < 0 || by >= bmp.height)
350
+ continue;
351
+ for (let cx = 0; cx < cel.width; cx++) {
352
+ const bx = left + cx;
353
+ if (bx < 0 || bx >= bmp.width)
354
+ continue;
355
+ const c = cel.pixels[cy * cel.width + (mirror ? cel.width - 1 - cx : cx)];
356
+ if (c !== cel.skipColor)
357
+ bmp.pixels[by * bmp.width + bx] = c;
358
+ }
359
+ }
360
+ }
336
361
  return 0;
337
362
  },
338
363
  };
@@ -321,8 +321,29 @@ export const textKernels: Record<string, KernelFn> = {
321
321
  return text !== original ? 1 : 0;
322
322
  },
323
323
 
324
- Bitmap: (vm, [subop = 0, ref = 0]) => {
325
- if (subop === 1 && vm.memory.bitmap(ref)) vm.memory.free(segmentOf(ref));
324
+ // Bitmap(1, bitmap): dispose. Bitmap(3, bitmap, view, loop, cel, x, y): draw a view's cel
325
+ // into the bitmap with its origin at (x, y), skipping its transparent pixels (SCI2.1's
326
+ // kBitmapDrawView; the class library frames text boxes with it).
327
+ Bitmap: (vm, [subop = 0, ref = 0, ...args]) => {
328
+ const bmp = vm.memory.bitmap(ref);
329
+ if (subop === 1 && bmp) vm.memory.free(segmentOf(ref));
330
+ if (subop === 3 && bmp) {
331
+ const [view = 0, loop = 0, celNo = 0, x = 0, y = 0] = args.map(toSigned);
332
+ const found = graphics(vm).cel(view, loop, celNo);
333
+ if (!found) return 0;
334
+ const { cel, mirror } = found;
335
+ const left = x - ((cel.width >> 1) - cel.displaceX), top = y - (cel.height - 1 - cel.displaceY);
336
+ for (let cy = 0; cy < cel.height; cy++) {
337
+ const by = top + cy;
338
+ if (by < 0 || by >= bmp.height) continue;
339
+ for (let cx = 0; cx < cel.width; cx++) {
340
+ const bx = left + cx;
341
+ if (bx < 0 || bx >= bmp.width) continue;
342
+ const c = cel.pixels[cy * cel.width + (mirror ? cel.width - 1 - cx : cx)]!;
343
+ if (c !== cel.skipColor) bmp.pixels[by * bmp.width + bx] = c;
344
+ }
345
+ }
346
+ }
326
347
  return 0;
327
348
  },
328
349
  };
@@ -24,6 +24,16 @@ export interface ArtManifest {
24
24
  mirror: boolean;
25
25
  })[];
26
26
  }[];
27
+ /** Fonts drawn as sheets of glyphs (tools/art/font.ts). */
28
+ fonts?: {
29
+ number: number;
30
+ png: string;
31
+ cell: [number, number];
32
+ first?: number;
33
+ spacing?: number;
34
+ space?: number;
35
+ lineHeight?: number;
36
+ }[];
27
37
  }
28
38
  export declare class ArtError extends Error {
29
39
  }
@@ -1,8 +1,9 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { dirname, resolve } from "node:path";
3
- import { ResourceType, writeHunkPalette, writePic, writeView } from "../../packages/sci/src/index.js";
3
+ import { ResourceType, writeFont, writeHunkPalette, writePic, writeView } from "../../packages/sci/src/index.js";
4
4
  import { basePalette } from "../game/defaults.js";
5
5
  import { decodePng } from "../png.js";
6
+ import { FontSheetError, fontFromSheet } from "./font.js";
6
7
  export class ArtError extends Error {
7
8
  }
8
9
  export function buildArt(file) {
@@ -38,7 +39,7 @@ export function buildArt(file) {
38
39
  return fail(where, "expected a file path");
39
40
  return resolve(dirname(file), value);
40
41
  };
41
- const m = object(json(file), "manifest", ["version", "palette", "maxColours", "pictures", "views"]);
42
+ const m = object(json(file), "manifest", ["version", "palette", "maxColours", "pictures", "views", "fonts"]);
42
43
  if (m.version !== 1)
43
44
  fail("version", "expected 1");
44
45
  const maxColours = m.maxColours === undefined ? 254 : int(m.maxColours, "maxColours", 2, 254);
@@ -170,6 +171,35 @@ export function buildArt(file) {
170
171
  fail(where, "a view supports at most 255 stored cels");
171
172
  resources.push({ type: ResourceType.View, number: n, data: writeView({ flags: 1, loops, palette }) });
172
173
  });
174
+ list(m.fonts ?? [], "fonts", 0, 65536).forEach((value, i) => {
175
+ const where = `fonts[${i}]`;
176
+ const f = object(value, where, ["number", "png", "cell", "first", "spacing", "space", "lineHeight"]);
177
+ const n = number(f.number, "font", `${where}.number`);
178
+ const filename = path(f.png, `${where}.png`);
179
+ let img;
180
+ try {
181
+ img = decodePng(readFileSync(filename));
182
+ }
183
+ catch (e) {
184
+ return fail(`${where}.png`, `${filename}: ${e.message}`);
185
+ }
186
+ const cell = list(f.cell, `${where}.cell`, 2, 2);
187
+ const optional = (key, min, max) => (f[key] === undefined ? undefined : int(f[key], `${where}.${key}`, min, max));
188
+ try {
189
+ const font = fontFromSheet(img, {
190
+ cell: [int(cell[0], `${where}.cell[0]`, 1, 128), int(cell[1], `${where}.cell[1]`, 1, 128)],
191
+ first: optional("first", 0, 255), spacing: optional("spacing", 0, 16),
192
+ space: optional("space", 1, 128), lineHeight: optional("lineHeight", 1, 255),
193
+ });
194
+ resources.push({ type: ResourceType.Font, number: n, data: writeFont(font) });
195
+ images++;
196
+ }
197
+ catch (e) {
198
+ if (!(e instanceof FontSheetError))
199
+ throw e;
200
+ fail(`${where}.png`, e.message);
201
+ }
202
+ });
173
203
  resources.sort((a, b) => a.type - b.type || a.number - b.number);
174
204
  return { resources, colours, images };
175
205
  }
@@ -1,8 +1,9 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { dirname, resolve } from "node:path";
3
- import { ResourceType, writeHunkPalette, writePic, writeView, type ResourceData, type ViewLoop } from "../../packages/sci/src/index.ts";
3
+ import { ResourceType, writeFont, writeHunkPalette, writePic, writeView, type ResourceData, type ViewLoop } from "../../packages/sci/src/index.ts";
4
4
  import { basePalette } from "../game/defaults.ts";
5
5
  import { decodePng } from "../png.ts";
6
+ import { FontSheetError, fontFromSheet } from "./font.ts";
6
7
 
7
8
  /** Editor-neutral, exact-colour art for standalone games. See docs/art-workflow.md. */
8
9
  export interface ArtManifest {
@@ -12,6 +13,8 @@ export interface ArtManifest {
12
13
  maxColours?: number;
13
14
  pictures: { number: number; layers: { png: string; priority: number }[] }[];
14
15
  views: { number: number; loops: ({ cels: { png: string; anchor: [number, number] }[] } | { link: number; mirror: boolean })[] }[];
16
+ /** Fonts drawn as sheets of glyphs (tools/art/font.ts). */
17
+ fonts?: { number: number; png: string; cell: [number, number]; first?: number; spacing?: number; space?: number; lineHeight?: number }[];
15
18
  }
16
19
 
17
20
  export class ArtError extends Error {}
@@ -39,7 +42,7 @@ export function buildArt(file: string): { resources: ResourceData[]; colours: st
39
42
  if (typeof value !== "string" || !value.length) return fail(where, "expected a file path");
40
43
  return resolve(dirname(file), value);
41
44
  };
42
- const m = object(json(file), "manifest", ["version", "palette", "maxColours", "pictures", "views"]);
45
+ const m = object(json(file), "manifest", ["version", "palette", "maxColours", "pictures", "views", "fonts"]);
43
46
  if (m.version !== 1) fail("version", "expected 1");
44
47
  const maxColours = m.maxColours === undefined ? 254 : int(m.maxColours, "maxColours", 2, 254);
45
48
  const paletteInput = json(path(m.palette, "palette"));
@@ -148,6 +151,29 @@ export function buildArt(file: string): { resources: ResourceData[]; colours: st
148
151
  if (total > 255) fail(where, "a view supports at most 255 stored cels");
149
152
  resources.push({ type: ResourceType.View, number: n, data: writeView({ flags: 1, loops, palette }) });
150
153
  });
154
+ list(m.fonts ?? [], "fonts", 0, 65536).forEach((value, i) => {
155
+ const where = `fonts[${i}]`;
156
+ const f = object(value, where, ["number", "png", "cell", "first", "spacing", "space", "lineHeight"]);
157
+ const n = number(f.number, "font", `${where}.number`);
158
+ const filename = path(f.png, `${where}.png`);
159
+ let img: ReturnType<typeof decodePng>;
160
+ try { img = decodePng(readFileSync(filename)); }
161
+ catch (e) { return fail(`${where}.png`, `${filename}: ${(e as Error).message}`); }
162
+ const cell = list(f.cell, `${where}.cell`, 2, 2);
163
+ const optional = (key: string, min: number, max: number) => (f[key] === undefined ? undefined : int(f[key], `${where}.${key}`, min, max));
164
+ try {
165
+ const font = fontFromSheet(img, {
166
+ cell: [int(cell[0], `${where}.cell[0]`, 1, 128), int(cell[1], `${where}.cell[1]`, 1, 128)],
167
+ first: optional("first", 0, 255), spacing: optional("spacing", 0, 16),
168
+ space: optional("space", 1, 128), lineHeight: optional("lineHeight", 1, 255),
169
+ });
170
+ resources.push({ type: ResourceType.Font, number: n, data: writeFont(font) });
171
+ images++;
172
+ } catch (e) {
173
+ if (!(e instanceof FontSheetError)) throw e;
174
+ fail(`${where}.png`, e.message);
175
+ }
176
+ });
151
177
  resources.sort((a, b) => a.type - b.type || a.number - b.number);
152
178
  return { resources, colours, images };
153
179
  }
@@ -0,0 +1,24 @@
1
+ import type { Font } from "../../packages/sci/src/index.ts";
2
+ import type { Rgba } from "../png.ts";
3
+ /**
4
+ * A font from a sheet of glyphs drawn in a paint program: a grid of same-size cells, one
5
+ * character per cell, left to right and top to bottom from `first`. A glyph is drawn from
6
+ * its cell's left edge; any opaque pixel is ink (its colour doesn't matter: text takes its
7
+ * colour when it's drawn), and its width is up to its rightmost ink. A cell with no ink is
8
+ * a character the font doesn't have, except the space, which is `space` pixels wide.
9
+ */
10
+ export interface FontSheet {
11
+ /** Cell size in pixels: [width, height]. */
12
+ cell: [number, number];
13
+ /** The character in the first cell (default 32, the space). */
14
+ first?: number;
15
+ /** Pixels between one glyph and the next (default 1). */
16
+ spacing?: number;
17
+ /** Width of the space (default half a cell). */
18
+ space?: number;
19
+ /** Line height (default the cell height). */
20
+ lineHeight?: number;
21
+ }
22
+ export declare class FontSheetError extends Error {
23
+ }
24
+ export declare function fontFromSheet(img: Rgba, sheet: FontSheet): Font;
@@ -0,0 +1,40 @@
1
+ export class FontSheetError extends Error {
2
+ }
3
+ export function fontFromSheet(img, sheet) {
4
+ const [cw, ch] = sheet.cell;
5
+ const first = sheet.first ?? 32, spacing = sheet.spacing ?? 1;
6
+ const space = sheet.space ?? Math.max(1, cw >> 1), lineHeight = sheet.lineHeight ?? ch;
7
+ const fail = (message) => { throw new FontSheetError(message); };
8
+ if (img.width % cw || img.height % ch)
9
+ fail(`${img.width}x${img.height} is not a whole number of ${cw}x${ch} cells`);
10
+ const columns = img.width / cw, cells = columns * (img.height / ch);
11
+ if (first + cells > 256)
12
+ fail(`${cells} cells from character ${first} go past 255`);
13
+ if (lineHeight < 1 || lineHeight > 255)
14
+ fail(`line height ${lineHeight} is outside 1..255`);
15
+ const glyphs = Array.from({ length: Math.max(128, first + cells) }, () => ({ width: 0, height: 0, pixels: new Uint8Array(0) }));
16
+ for (let i = 0; i < cells; i++) {
17
+ const left = (i % columns) * cw, top = Math.floor(i / columns) * ch;
18
+ const ink = new Uint8Array(cw * ch);
19
+ let right = -1;
20
+ for (let y = 0; y < ch; y++) {
21
+ for (let x = 0; x < cw; x++) {
22
+ const a = img.data[((top + y) * img.width + left + x) * 4 + 3];
23
+ if (a !== 0 && a !== 255)
24
+ fail(`character ${first + i} (${left + x},${top + y}): alpha ${a}; export without antialiasing`);
25
+ if (a)
26
+ (ink[y * cw + x] = 1), (right = Math.max(right, x));
27
+ }
28
+ }
29
+ const code = first + i;
30
+ if (right < 0 && code !== 32)
31
+ continue;
32
+ const inked = right + 1, width = (code === 32 && right < 0 ? space : inked + spacing);
33
+ const pixels = new Uint8Array(width * ch);
34
+ for (let y = 0; y < ch; y++)
35
+ for (let x = 0; x < inked; x++)
36
+ pixels[y * width + x] = ink[y * cw + x];
37
+ glyphs[code] = { width, height: ch, pixels };
38
+ }
39
+ return { height: lineHeight, glyphs };
40
+ }
@@ -0,0 +1,56 @@
1
+ import type { Font, Glyph } from "../../packages/sci/src/index.ts";
2
+ import type { Rgba } from "../png.ts";
3
+
4
+ /**
5
+ * A font from a sheet of glyphs drawn in a paint program: a grid of same-size cells, one
6
+ * character per cell, left to right and top to bottom from `first`. A glyph is drawn from
7
+ * its cell's left edge; any opaque pixel is ink (its colour doesn't matter: text takes its
8
+ * colour when it's drawn), and its width is up to its rightmost ink. A cell with no ink is
9
+ * a character the font doesn't have, except the space, which is `space` pixels wide.
10
+ */
11
+ export interface FontSheet {
12
+ /** Cell size in pixels: [width, height]. */
13
+ cell: [number, number];
14
+ /** The character in the first cell (default 32, the space). */
15
+ first?: number;
16
+ /** Pixels between one glyph and the next (default 1). */
17
+ spacing?: number;
18
+ /** Width of the space (default half a cell). */
19
+ space?: number;
20
+ /** Line height (default the cell height). */
21
+ lineHeight?: number;
22
+ }
23
+
24
+ export class FontSheetError extends Error {}
25
+
26
+ export function fontFromSheet(img: Rgba, sheet: FontSheet): Font {
27
+ const [cw, ch] = sheet.cell;
28
+ const first = sheet.first ?? 32, spacing = sheet.spacing ?? 1;
29
+ const space = sheet.space ?? Math.max(1, cw >> 1), lineHeight = sheet.lineHeight ?? ch;
30
+ const fail = (message: string): never => { throw new FontSheetError(message); };
31
+ if (img.width % cw || img.height % ch) fail(`${img.width}x${img.height} is not a whole number of ${cw}x${ch} cells`);
32
+ const columns = img.width / cw, cells = columns * (img.height / ch);
33
+ if (first + cells > 256) fail(`${cells} cells from character ${first} go past 255`);
34
+ if (lineHeight < 1 || lineHeight > 255) fail(`line height ${lineHeight} is outside 1..255`);
35
+
36
+ const glyphs: Glyph[] = Array.from({ length: Math.max(128, first + cells) }, () => ({ width: 0, height: 0, pixels: new Uint8Array(0) }));
37
+ for (let i = 0; i < cells; i++) {
38
+ const left = (i % columns) * cw, top = Math.floor(i / columns) * ch;
39
+ const ink = new Uint8Array(cw * ch);
40
+ let right = -1;
41
+ for (let y = 0; y < ch; y++) {
42
+ for (let x = 0; x < cw; x++) {
43
+ const a = img.data[((top + y) * img.width + left + x) * 4 + 3]!;
44
+ if (a !== 0 && a !== 255) fail(`character ${first + i} (${left + x},${top + y}): alpha ${a}; export without antialiasing`);
45
+ if (a) (ink[y * cw + x] = 1), (right = Math.max(right, x));
46
+ }
47
+ }
48
+ const code = first + i;
49
+ if (right < 0 && code !== 32) continue;
50
+ const inked = right + 1, width = (code === 32 && right < 0 ? space : inked + spacing);
51
+ const pixels = new Uint8Array(width * ch);
52
+ for (let y = 0; y < ch; y++) for (let x = 0; x < inked; x++) pixels[y * width + x] = ink[y * cw + x]!;
53
+ glyphs[code] = { width, height: ch, pixels };
54
+ }
55
+ return { height: lineHeight, glyphs };
56
+ }
@@ -9,6 +9,8 @@ import { type ResourceData } from "../../packages/sci/src/index.ts";
9
9
  * games/<name>/rooms/<n>.room.yaml (+ <n>.yarn) rooms as data (packages/content), compiled
10
10
  * for the class library; their Yarn variables are flags
11
11
  * numbered in flags.yaml
12
+ * games/<name>/items.yaml optional: the verbs of the things the hero can carry
13
+ * (`lens: 10`), so rooms' Yarn can answer them (filings.lens)
12
14
  * games/<name>/music/<n>.mid music: sound n, from a Standard MIDI File (General MIDI)
13
15
  * games/<name>/sounds/<n>.wav digital effects: sound n plays this instead of music
14
16
  * games/<name>/resources.ts optional: `export default () => ResourceData[]` for art,
@@ -44,3 +46,5 @@ export declare const LIBRARY_DIR: string;
44
46
  export declare function buildGame(dir: string, options?: {
45
47
  library?: boolean;
46
48
  }): Promise<BuiltGame>;
49
+ /** items.yaml: `name: verb` lines, each item's verb from 10 to 255 (1 to 4 are the player's). */
50
+ export declare function itemVerbs(dir: string): Record<string, number>;
@@ -108,7 +108,7 @@ export async function buildGame(dir, options = {}) {
108
108
  const roomsDir = join(dir, "rooms");
109
109
  if (!existsSync(roomsDir))
110
110
  return [];
111
- const target = libraryTarget(globals);
111
+ const target = libraryTarget(globals, itemVerbs(dir));
112
112
  const flags = modFlags(dir, { content: target });
113
113
  const out = [];
114
114
  for (const { file, text } of load(roomsDir, /^\d+\.room\.yaml$/)) {
@@ -257,3 +257,29 @@ export async function buildGame(dir, options = {}) {
257
257
  warnings: [...new Set(warnings)],
258
258
  };
259
259
  }
260
+ /** items.yaml: `name: verb` lines, each item's verb from 10 to 255 (1 to 4 are the player's). */
261
+ export function itemVerbs(dir) {
262
+ const file = join(dir, "items.yaml");
263
+ if (!existsSync(file))
264
+ return {};
265
+ const where = relative(process.cwd(), file);
266
+ const verbs = {};
267
+ readFileSync(file, "utf8").split("\n").forEach((raw, i) => {
268
+ const line = raw.replace(/#.*/, "").trim();
269
+ if (!line)
270
+ return;
271
+ const at = `${where}:${i + 1}`;
272
+ const m = /^([A-Za-z]\w*)\s*:\s*(\d+)$/.exec(line);
273
+ if (!m)
274
+ throw new GameBuildError(`${at}: expected item: verb`);
275
+ const [, name, n] = m, verb = Number(n);
276
+ if (!/^[a-z][A-Za-z0-9]*$/.test(name) || ["look", "talk", "walk", "do", "enter"].includes(name))
277
+ throw new GameBuildError(`${at}: "${name}" can't name an item (lower camelCase, and not a verb)`);
278
+ if (verb < 10 || verb > 255)
279
+ throw new GameBuildError(`${at}: ${name}: the verb is a number from 10 to 255`);
280
+ if (Object.values(verbs).includes(verb))
281
+ throw new GameBuildError(`${at}: ${name}: verb ${verb} is used twice`);
282
+ verbs[name] = verb;
283
+ });
284
+ return verbs;
285
+ }
@@ -22,6 +22,8 @@ import { libraryTarget } from "./target.ts";
22
22
  * games/<name>/rooms/<n>.room.yaml (+ <n>.yarn) rooms as data (packages/content), compiled
23
23
  * for the class library; their Yarn variables are flags
24
24
  * numbered in flags.yaml
25
+ * games/<name>/items.yaml optional: the verbs of the things the hero can carry
26
+ * (`lens: 10`), so rooms' Yarn can answer them (filings.lens)
25
27
  * games/<name>/music/<n>.mid music: sound n, from a Standard MIDI File (General MIDI)
26
28
  * games/<name>/sounds/<n>.wav digital effects: sound n plays this instead of music
27
29
  * games/<name>/resources.ts optional: `export default () => ResourceData[]` for art,
@@ -140,7 +142,7 @@ export async function buildGame(dir: string, options: { library?: boolean } = {}
140
142
  const compileRooms = (): { file: string; text: string }[] => {
141
143
  const roomsDir = join(dir, "rooms");
142
144
  if (!existsSync(roomsDir)) return [];
143
- const target = libraryTarget(globals);
145
+ const target = libraryTarget(globals, itemVerbs(dir));
144
146
  const flags = modFlags(dir, { content: target });
145
147
  const out: { file: string; text: string }[] = [];
146
148
  for (const { file, text } of load(roomsDir, /^\d+\.room\.yaml$/)) {
@@ -281,3 +283,24 @@ export async function buildGame(dir: string, options: { library?: boolean } = {}
281
283
  warnings: [...new Set(warnings)],
282
284
  };
283
285
  }
286
+
287
+ /** items.yaml: `name: verb` lines, each item's verb from 10 to 255 (1 to 4 are the player's). */
288
+ export function itemVerbs(dir: string): Record<string, number> {
289
+ const file = join(dir, "items.yaml");
290
+ if (!existsSync(file)) return {};
291
+ const where = relative(process.cwd(), file);
292
+ const verbs: Record<string, number> = {};
293
+ readFileSync(file, "utf8").split("\n").forEach((raw, i) => {
294
+ const line = raw.replace(/#.*/, "").trim();
295
+ if (!line) return;
296
+ const at = `${where}:${i + 1}`;
297
+ const m = /^([A-Za-z]\w*)\s*:\s*(\d+)$/.exec(line);
298
+ if (!m) throw new GameBuildError(`${at}: expected item: verb`);
299
+ const [, name, n] = m, verb = Number(n);
300
+ if (!/^[a-z][A-Za-z0-9]*$/.test(name!) || ["look", "talk", "walk", "do", "enter"].includes(name!)) throw new GameBuildError(`${at}: "${name}" can't name an item (lower camelCase, and not a verb)`);
301
+ if (verb < 10 || verb > 255) throw new GameBuildError(`${at}: ${name}: the verb is a number from 10 to 255`);
302
+ if (Object.values(verbs).includes(verb)) throw new GameBuildError(`${at}: ${name}: verb ${verb} is used twice`);
303
+ verbs[name!] = verb;
304
+ });
305
+ return verbs;
306
+ }
@@ -5,3 +5,4 @@
5
5
  export { ResourceType, SoundDevice, writeFont, writeHunkPalette, writePic, writeSound, writeView, type Cel, type Font, type HunkPaletteFile, type MidiEvent, type PicFile, type ResourceData, type SoundSpec, type ViewFile, } from "../../packages/sci/src/index.ts";
6
6
  export { BASE_PALETTE, CURSOR_VIEW, Colour, DEFAULT_FONT, basePalette, cube } from "./defaults.ts";
7
7
  export { pixelFont } from "./font.ts";
8
+ export { fontFromSheet, type FontSheet } from "../art/font.ts";
package/tools/game/kit.js CHANGED
@@ -5,3 +5,4 @@
5
5
  export { ResourceType, SoundDevice, writeFont, writeHunkPalette, writePic, writeSound, writeView, } from "../../packages/sci/src/index.js";
6
6
  export { BASE_PALETTE, CURSOR_VIEW, Colour, DEFAULT_FONT, basePalette, cube } from "./defaults.js";
7
7
  export { pixelFont } from "./font.js";
8
+ export { fontFromSheet } from "../art/font.js";
package/tools/game/kit.ts CHANGED
@@ -8,3 +8,4 @@ export {
8
8
  } from "../../packages/sci/src/index.ts";
9
9
  export { BASE_PALETTE, CURSOR_VIEW, Colour, DEFAULT_FONT, basePalette, cube } from "./defaults.ts";
10
10
  export { pixelFont } from "./font.ts";
11
+ export { fontFromSheet, type FontSheet } from "../art/font.ts";
@@ -4,4 +4,4 @@ import type { Target } from "../../packages/content/src/index.ts";
4
4
  * (packages/content). Global numbers depend on the game's script 0, so the target is made
5
5
  * for each build from the compiled globals' names.
6
6
  */
7
- export declare function libraryTarget(globals: readonly string[]): Target;
7
+ export declare function libraryTarget(globals: readonly string[], items?: Record<string, number>): Target;
@@ -3,7 +3,7 @@
3
3
  * (packages/content). Global numbers depend on the game's script 0, so the target is made
4
4
  * for each build from the compiled globals' names.
5
5
  */
6
- export function libraryTarget(globals) {
6
+ export function libraryTarget(globals, items = {}) {
7
7
  const g = (name) => {
8
8
  const n = globals.indexOf(name);
9
9
  if (n < 0)
@@ -28,12 +28,14 @@ export function libraryTarget(globals) {
28
28
  firstRoomTalker: 200,
29
29
  voice: { class: "Talker", props: {} },
30
30
  // Portraits stand at the top left, the text beside them.
31
- portrait: { class: "PortraitTalker", props: {}, at: [36, 70], frameSignal: 0, partSignal: 0 },
31
+ portrait: { class: "PortraitTalker", props: {}, at: [8, 8], frameSignal: 0, partSignal: 0 },
32
32
  teller: { class: "Teller", props: {}, verb: 2 },
33
33
  forwardCycle: "Forward",
34
+ closeUp: "CloseUp",
34
35
  // room.enter lines: a verb the player can't use.
35
36
  narrationVerb: 0,
36
- verbs: { look: 1, talk: 2, walk: 3, do: 4 },
37
+ // And the game's items (items.yaml): using one is its own verb.
38
+ verbs: { look: 1, talk: 2, walk: 3, do: 4, ...items },
37
39
  messageVersion: 4321,
38
40
  // SetFlag, ClearFlag and IsFlag: script 999's exports 0, 1 and 2.
39
41
  flags: { script: 999, set: 0, clear: 1, test: 2, first: 0, last: 1023 },
@@ -5,7 +5,7 @@ import type { Target } from "../../packages/content/src/index.ts";
5
5
  * (packages/content). Global numbers depend on the game's script 0, so the target is made
6
6
  * for each build from the compiled globals' names.
7
7
  */
8
- export function libraryTarget(globals: readonly string[]): Target {
8
+ export function libraryTarget(globals: readonly string[], items: Record<string, number> = {}): Target {
9
9
  const g = (name: string) => {
10
10
  const n = globals.indexOf(name);
11
11
  if (n < 0) throw new Error(`the library's global ${name} isn't there: is script 0 compiled with the library?`);
@@ -29,12 +29,14 @@ export function libraryTarget(globals: readonly string[]): Target {
29
29
  firstRoomTalker: 200,
30
30
  voice: { class: "Talker", props: {} },
31
31
  // Portraits stand at the top left, the text beside them.
32
- portrait: { class: "PortraitTalker", props: {}, at: [36, 70], frameSignal: 0, partSignal: 0 },
32
+ portrait: { class: "PortraitTalker", props: {}, at: [8, 8], frameSignal: 0, partSignal: 0 },
33
33
  teller: { class: "Teller", props: {}, verb: 2 },
34
34
  forwardCycle: "Forward",
35
+ closeUp: "CloseUp",
35
36
  // room.enter lines: a verb the player can't use.
36
37
  narrationVerb: 0,
37
- verbs: { look: 1, talk: 2, walk: 3, do: 4 },
38
+ // And the game's items (items.yaml): using one is its own verb.
39
+ verbs: { look: 1, talk: 2, walk: 3, do: 4, ...items },
38
40
  messageVersion: 4321,
39
41
  // SetFlag, ClearFlag and IsFlag: script 999's exports 0, 1 and 2.
40
42
  flags: { script: 999, set: 0, clear: 1, test: 2, first: 0, last: 1023 },