sci2-ts 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/apps/viewer/src/files.js +3 -1
- package/apps/viewer/src/files.ts +3 -1
- package/lib/992.sc +17 -3
- package/lib/996.sc +86 -32
- package/lib/999.sc +2 -1
- package/lib/system.sh +9 -0
- package/package.json +1 -1
- package/packages/content/room.schema.json +24 -1
- package/packages/content/src/room.d.ts +45 -2
- package/packages/content/src/room.js +69 -7
- package/packages/content/src/room.ts +102 -9
- package/packages/content/src/yarn.d.ts +13 -0
- package/packages/content/src/yarn.js +52 -2
- package/packages/content/src/yarn.ts +51 -2
- package/packages/sci/src/audio/sol.d.ts +2 -0
- package/packages/sci/src/audio/sol.js +2 -1
- package/packages/sci/src/audio/sol.ts +2 -1
- package/packages/sci/src/audio/write.d.ts +37 -2
- package/packages/sci/src/audio/write.js +96 -7
- package/packages/sci/src/audio/write.ts +96 -7
- package/packages/sci/src/vm/kernels/audio.js +1 -1
- package/packages/sci/src/vm/kernels/audio.ts +1 -1
- package/tools/cli.js +36 -1
- package/tools/cli.ts +30 -1
- package/tools/game/audio.d.ts +14 -0
- package/tools/game/audio.js +62 -0
- package/tools/game/audio.ts +58 -0
- package/tools/game/build.d.ts +7 -1
- package/tools/game/build.js +59 -2
- package/tools/game/build.ts +62 -4
- package/tools/game/lines.d.ts +8 -0
- package/tools/game/lines.js +53 -0
- package/tools/game/lines.ts +44 -0
- package/tools/game/voices.d.ts +37 -0
- package/tools/game/voices.js +44 -0
- package/tools/game/voices.ts +67 -0
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.11.0](https://github.com/rlueder/sci-ts/compare/v0.10.0...v0.11.0) (2026-10-02)
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
* **lib:** menus as one framed box, and text boxes that sit on the bottom of the screen ([#13](https://github.com/rlueder/sci-ts/issues/13)) ([83cf03d](https://github.com/rlueder/sci-ts/commit/83cf03d0bb4e044d24cbff769a102294e3a66a03))
|
|
12
|
+
|
|
13
|
+
## [0.10.0](https://github.com/rlueder/sci-ts/compare/v0.9.0...v0.10.0) (2026-10-02)
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
* perspective for rooms, and spoken lines ([#12](https://github.com/rlueder/sci-ts/issues/12)) ([b8a504b](https://github.com/rlueder/sci-ts/commit/b8a504bc972c85818f37f00597ac209ff6916843)), closes [#11](https://github.com/rlueder/sci-ts/issues/11)
|
|
18
|
+
|
|
7
19
|
## [0.9.0](https://github.com/rlueder/sci-ts/compare/v0.8.2...v0.9.0) (2026-10-02)
|
|
8
20
|
|
|
9
21
|
### Added
|
package/apps/viewer/src/files.js
CHANGED
|
@@ -13,7 +13,9 @@ export const httpFiles = {
|
|
|
13
13
|
},
|
|
14
14
|
async readRange(path, offset, length) {
|
|
15
15
|
const res = await fetch(`${BASE}game/${path}`, { headers: { Range: `bytes=${offset}-${offset + length - 1}` } });
|
|
16
|
-
|
|
16
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
17
|
+
// A server that ignores Range (Python's http.server) sends the whole file.
|
|
18
|
+
return res.status === 206 ? bytes : bytes.subarray(offset, offset + length);
|
|
17
19
|
},
|
|
18
20
|
async list(dir) {
|
|
19
21
|
if (import.meta.env.DEV)
|
package/apps/viewer/src/files.ts
CHANGED
|
@@ -15,7 +15,9 @@ export const httpFiles: FileSource = {
|
|
|
15
15
|
},
|
|
16
16
|
async readRange(path, offset, length) {
|
|
17
17
|
const res = await fetch(`${BASE}game/${path}`, { headers: { Range: `bytes=${offset}-${offset + length - 1}` } });
|
|
18
|
-
|
|
18
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
19
|
+
// A server that ignores Range (Python's http.server) sends the whole file.
|
|
20
|
+
return res.status === 206 ? bytes : bytes.subarray(offset, offset + length);
|
|
19
21
|
},
|
|
20
22
|
async list(dir) {
|
|
21
23
|
if (import.meta.env.DEV) return (await fetch(`/__list?dir=${encodeURIComponent(dir)}`)).json();
|
package/lib/992.sc
CHANGED
|
@@ -58,9 +58,17 @@
|
|
|
58
58
|
add: SR_SAVE "Save the game"
|
|
59
59
|
add: SR_RESTORE "Restore a game"
|
|
60
60
|
add: SR_RESTART "Start again"
|
|
61
|
-
add: SR_TEXT (String STRING_FORMAT "Text speed: %s" (self textSpeedName:))
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
add: SR_TEXT (String STRING_FORMAT "Text speed: %s" (self textSpeedName:)))
|
|
62
|
+
;; Only a game with recorded lines asks how to have them.
|
|
63
|
+
(if (DoAudio 9)
|
|
64
|
+
(menu add: SR_SPEECH (String STRING_FORMAT "Speech: %s" (self speechName:))))
|
|
65
|
+
(menu add: 0 "Carry on" show: self))
|
|
66
|
+
|
|
67
|
+
(method (speechName)
|
|
68
|
+
(switch speech
|
|
69
|
+
(SPEECH_TEXT (return "text only"))
|
|
70
|
+
(SPEECH_VOICE (return "voice only"))
|
|
71
|
+
(else (return "voice and text"))))
|
|
64
72
|
|
|
65
73
|
(method (textSpeedName)
|
|
66
74
|
(switch textSpeed
|
|
@@ -133,6 +141,12 @@
|
|
|
133
141
|
(TEXT_NORMAL (= textSpeed TEXT_FAST))
|
|
134
142
|
(TEXT_FAST (= textSpeed TEXT_CLICK))
|
|
135
143
|
(else (= textSpeed TEXT_SLOW)))
|
|
144
|
+
(self showMenu:))
|
|
145
|
+
(SR_SPEECH
|
|
146
|
+
(switch speech
|
|
147
|
+
(SPEECH_BOTH (= speech SPEECH_VOICE))
|
|
148
|
+
(SPEECH_VOICE (= speech SPEECH_TEXT))
|
|
149
|
+
(else (= speech SPEECH_BOTH)))
|
|
136
150
|
(self showMenu:))))
|
|
137
151
|
(SR_SAVE (if v (self askDescription: (- v 1))))
|
|
138
152
|
(SR_RESTORE
|
package/lib/996.sc
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
;; A box of text over the room: drawn by the interpreter into a bitmap, shown as a screen
|
|
23
23
|
;; item in the UI plane. Set text (and width, x, y), then init; it's as tall as the text
|
|
24
24
|
;; unless height is set. Font, colours and frame come from the textStyle unless set here.
|
|
25
|
+
;; With bottom set, y is worked out so the box ends there: it grows upwards with the text.
|
|
25
26
|
(class TextItem of Obj
|
|
26
27
|
(properties
|
|
27
28
|
x 0 y 0 z 0
|
|
@@ -40,7 +41,8 @@
|
|
|
40
41
|
frame -2 ; -1: none
|
|
41
42
|
textLeft 0 textTop 0 textRight -1 textBottom -1
|
|
42
43
|
width 200
|
|
43
|
-
height 0
|
|
44
|
+
height 0
|
|
45
|
+
bottom -1) ; -1: placed at y
|
|
44
46
|
|
|
45
47
|
(method (init &tmp r m padL padT padR padB)
|
|
46
48
|
(if (== font -1) (= font (textStyle font?)))
|
|
@@ -65,6 +67,7 @@
|
|
|
65
67
|
(= textTop padT)
|
|
66
68
|
(= textRight (- width (+ padR 1)))
|
|
67
69
|
(= textBottom (- height (+ padB 1)))
|
|
70
|
+
(if (!= bottom -1) (= y (- bottom height)))
|
|
68
71
|
(= plane uiPlane)
|
|
69
72
|
(= bitmap (CreateTextBitmap 0 width height self))
|
|
70
73
|
(if (!= frame -1) (self drawFrame:))
|
|
@@ -96,33 +99,45 @@
|
|
|
96
99
|
;; Shows a line until it's been there long enough to read (as long as `textSpeed` says), or
|
|
97
100
|
;; the player clicks or presses Enter, Space or "."; then cues whoever asked. The narrator
|
|
98
101
|
;; speaks for no one in particular.
|
|
102
|
+
;;
|
|
103
|
+
;; A line from a message file (the Messager sets module, noun, verb, cond and seq before
|
|
104
|
+
;; say:) is heard too if it has a recording, unless `speech` is SPEECH_TEXT. A heard line
|
|
105
|
+
;; stays until its recording ends, and a moment more; with SPEECH_VOICE it isn't shown.
|
|
99
106
|
(class Narrator of Obj
|
|
100
107
|
(properties
|
|
101
108
|
caller 0
|
|
102
109
|
box 0
|
|
103
|
-
shownAt 0 ; the game time it went up
|
|
104
|
-
readFor 0 ; how long it stays (-1: until dismissed)
|
|
110
|
+
shownAt 0 ; the game time it went up (or its recording ended)
|
|
111
|
+
readFor 0 ; how long it stays (-1: until dismissed, or while it's heard)
|
|
112
|
+
voiced 0 ; its recording is playing
|
|
113
|
+
module 0 ; the message the next say: is, if it's one (set by the Messager)
|
|
114
|
+
noun 0 verb 0 cond 0 seq 0
|
|
105
115
|
font -1 ; -1: the textStyle's
|
|
106
116
|
x -1 ; -1: centred
|
|
107
|
-
y 16
|
|
117
|
+
y 16 ; below 0: that far above the bottom of the screen, growing upwards
|
|
108
118
|
width 220)
|
|
109
119
|
|
|
110
120
|
(method (say txt whoCares)
|
|
111
121
|
(self clear:)
|
|
112
122
|
(if (and talking (!= talking self)) (talking clear:))
|
|
113
123
|
(= caller (if (> argc 1) whoCares else 0))
|
|
114
|
-
(
|
|
115
|
-
((
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
124
|
+
(if (and module (!= speech SPEECH_TEXT))
|
|
125
|
+
(= voiced (!= 0 (DoAudio 2 module noun verb cond seq))))
|
|
126
|
+
(= module 0)
|
|
127
|
+
(if (or (not voiced) (!= speech SPEECH_VOICE))
|
|
128
|
+
(= box
|
|
129
|
+
((TextItem new:)
|
|
130
|
+
text: txt
|
|
131
|
+
font: font
|
|
132
|
+
width: width
|
|
133
|
+
x: (if (== x -1) (/ (- SCREEN_WIDTH width) 2) else x)
|
|
134
|
+
y: (if (< y 0) 0 else y)
|
|
135
|
+
bottom: (if (< y 0) (+ SCREEN_HEIGHT y) else -1)
|
|
136
|
+
yourself:))
|
|
137
|
+
(box init:))
|
|
123
138
|
(= talking self)
|
|
124
139
|
(= shownAt gameTime)
|
|
125
|
-
(= readFor (self readingTime: (String STRING_LENGTH txt))))
|
|
140
|
+
(= readFor (if voiced -1 else (self readingTime: (String STRING_LENGTH txt)))))
|
|
126
141
|
|
|
127
142
|
;; Cycles to read a line of n characters: normally two seconds, and more for longer lines;
|
|
128
143
|
;; -1 is until the player dismisses it.
|
|
@@ -134,12 +149,18 @@
|
|
|
134
149
|
(else (return (+ 120 (* 3 n))))))
|
|
135
150
|
|
|
136
151
|
;; Time is measured as cycles since, which stays right when gameTime wraps past 32767.
|
|
152
|
+
;; When a heard line's recording ends, it stays a moment (or until a click, if the text
|
|
153
|
+
;; is up and that's the text speed).
|
|
137
154
|
(method (doit)
|
|
138
|
-
(if (and
|
|
155
|
+
(if (and voiced (== (DoAudio 6) -1))
|
|
156
|
+
(= voiced 0)
|
|
157
|
+
(= shownAt gameTime)
|
|
158
|
+
(= readFor (if (and box (== textSpeed TEXT_CLICK)) -1 else VOICE_PAUSE)))
|
|
159
|
+
(if (and (== talking self) (!= readFor -1) (>= (- gameTime shownAt) readFor)) (self done:)))
|
|
139
160
|
|
|
140
161
|
;; A click, Enter, Space or "." while a line is up dismisses it (and nothing else).
|
|
141
162
|
(method (handleEvent event)
|
|
142
|
-
(if (and
|
|
163
|
+
(if (and (== talking self)
|
|
143
164
|
(or (== (event type?) EV_MOUSE_DOWN)
|
|
144
165
|
(and (== (event type?) EV_KEY_DOWN)
|
|
145
166
|
(or (== (event message?) KEY_ENTER) (== (event message?) KEY_SPACE) (== (event message?) KEY_PERIOD)))))
|
|
@@ -149,6 +170,7 @@
|
|
|
149
170
|
(return FALSE))
|
|
150
171
|
|
|
151
172
|
(method (clear)
|
|
173
|
+
(if voiced (DoAudio 3) (= voiced 0))
|
|
152
174
|
(if box (box dispose:) (= box 0))
|
|
153
175
|
(if (== talking self) (= talking 0)))
|
|
154
176
|
|
|
@@ -200,11 +222,12 @@
|
|
|
200
222
|
(if eyes (eyes setCycle: Blink))
|
|
201
223
|
(if mouth
|
|
202
224
|
(mouth setCycle: Forward)
|
|
203
|
-
;;
|
|
204
|
-
|
|
225
|
+
;; While it's heard; or about as long as it takes to say: half a second, and two
|
|
226
|
+
;; cycles a letter.
|
|
227
|
+
(= mouthFor (if voiced 0 else (+ 30 (* 2 (String STRING_LENGTH txt)))))))
|
|
205
228
|
|
|
206
229
|
(method (doit)
|
|
207
|
-
(if (and mouth (mouth cycler?) (>= (- gameTime shownAt) mouthFor))
|
|
230
|
+
(if (and mouth (mouth cycler?) (not voiced) (>= (- gameTime shownAt) mouthFor))
|
|
208
231
|
(mouth setCycle: 0 setCel: 0))
|
|
209
232
|
(super doit:))
|
|
210
233
|
|
|
@@ -221,7 +244,8 @@
|
|
|
221
244
|
(super clear:)))
|
|
222
245
|
|
|
223
246
|
;; Says the lines of a message file for a noun, verb and condition, in sequence:
|
|
224
|
-
;; (messager say: noun verb [cond [seq [caller [module]]]]). Each line goes to its talker
|
|
247
|
+
;; (messager say: noun verb [cond [seq [caller [module]]]]). Each line goes to its talker,
|
|
248
|
+
;; told which message it is so it can play the line's recording.
|
|
225
249
|
;; Returns whether there was anything to say; cues the caller after the last line.
|
|
226
250
|
(class Messager of Obj
|
|
227
251
|
(properties
|
|
@@ -245,9 +269,10 @@
|
|
|
245
269
|
(method (sayNext &tmp c talker)
|
|
246
270
|
(if (not buffer) (= buffer (String ARRAY_NEW 400)))
|
|
247
271
|
(if (Message 2 module noun verb cond seq)
|
|
248
|
-
(= talker (Message 0 module noun verb cond seq buffer))
|
|
272
|
+
(= talker (self findTalker: (Message 0 module noun verb cond seq buffer)))
|
|
273
|
+
(talker module: module noun: noun verb: verb cond: cond seq: seq)
|
|
249
274
|
(++ seq)
|
|
250
|
-
(
|
|
275
|
+
(talker say: buffer self)
|
|
251
276
|
else
|
|
252
277
|
(= c caller)
|
|
253
278
|
(= caller 0)
|
|
@@ -273,25 +298,53 @@
|
|
|
273
298
|
(String ARRAY_FREE text)
|
|
274
299
|
(super dispose:)))
|
|
275
300
|
|
|
276
|
-
;; Choices
|
|
277
|
-
;; gets every click (it's the `dialog`). The caller
|
|
301
|
+
;; Choices one under another in a box (the textStyle's frame around them all); the first
|
|
302
|
+
;; click on one ends it. While it's open it gets every click (it's the `dialog`). The caller
|
|
303
|
+
;; hears the choice by `choose: value`.
|
|
278
304
|
(class Menu of Obj
|
|
279
305
|
(properties
|
|
280
306
|
items 0
|
|
281
307
|
caller 0
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
308
|
+
panel 0
|
|
309
|
+
y 20 ; the top of the box
|
|
310
|
+
width 240
|
|
311
|
+
next 0) ; where the next choice goes
|
|
312
|
+
|
|
313
|
+
(method (add v txt &tmp item f)
|
|
314
|
+
(= f (textStyle frame?))
|
|
315
|
+
(if (not items)
|
|
316
|
+
(= items (List new:))
|
|
317
|
+
(= next (+ y (if (!= f -1) (CelHigh f 0 4) else 1))))
|
|
318
|
+
(= item
|
|
319
|
+
((MenuItem new:)
|
|
320
|
+
value: v
|
|
321
|
+
text: txt
|
|
322
|
+
frame: -1
|
|
323
|
+
borderColor: -1
|
|
324
|
+
width: (- width (if (!= f -1) (+ (CelWide f 0 6) (CelWide f 0 7)) else 2))
|
|
325
|
+
x: (+ (/ (- SCREEN_WIDTH width) 2) (if (!= f -1) (CelWide f 0 6) else 1))
|
|
326
|
+
y: next
|
|
327
|
+
yourself:))
|
|
288
328
|
(item init:)
|
|
289
|
-
(+=
|
|
329
|
+
(+= next (item height?))
|
|
290
330
|
(items add: item)
|
|
291
331
|
(return self))
|
|
292
332
|
|
|
293
|
-
|
|
333
|
+
;; The box behind the choices, then it waits for one.
|
|
334
|
+
(method (show whoCares &tmp f)
|
|
335
|
+
(= f (textStyle frame?))
|
|
294
336
|
(= caller whoCares)
|
|
337
|
+
(if items
|
|
338
|
+
(= panel
|
|
339
|
+
((TextItem new:)
|
|
340
|
+
text: ""
|
|
341
|
+
x: (/ (- SCREEN_WIDTH width) 2)
|
|
342
|
+
y: y
|
|
343
|
+
width: width
|
|
344
|
+
height: (+ (- next y) (if (!= f -1) (CelHigh f 0 5) else 1))
|
|
345
|
+
priority: 99
|
|
346
|
+
yourself:))
|
|
347
|
+
(panel init:))
|
|
295
348
|
(= dialog self))
|
|
296
349
|
|
|
297
350
|
(method (handleEvent event &tmp item v c)
|
|
@@ -315,6 +368,7 @@
|
|
|
315
368
|
(items eachElementDo: #dispose)
|
|
316
369
|
(items dispose:)
|
|
317
370
|
(= items 0))
|
|
371
|
+
(if panel (panel dispose:) (= panel 0))
|
|
318
372
|
(super dispose:)))
|
|
319
373
|
|
|
320
374
|
;; A conversation with something: talking to it opens a menu of topics, choosing one says
|
package/lib/999.sc
CHANGED
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
inventory ; what the hero carries (an Inventory)
|
|
33
33
|
theItem ; the item picked to use, or 0
|
|
34
34
|
iconBar ; the icon bar (an IconBar)
|
|
35
|
-
textSpeed
|
|
35
|
+
textSpeed ; how long a line stays up: TEXT_NORMAL, TEXT_SLOW, TEXT_FAST, TEXT_CLICK
|
|
36
|
+
speech) ; whether recorded lines are heard, read, or both: SPEECH_BOTH, SPEECH_TEXT, SPEECH_VOICE
|
|
36
37
|
|
|
37
38
|
(procedure (SetFlag n)
|
|
38
39
|
(|= [gameFlags (/ n 16)] (<< 1 (mod n 16))))
|
package/lib/system.sh
CHANGED
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
(define SR_RESTART 4)
|
|
46
46
|
(define SR_NAME 5)
|
|
47
47
|
(define SR_TEXT 6)
|
|
48
|
+
(define SR_SPEECH 7)
|
|
48
49
|
|
|
49
50
|
;; How long a line stays up (the textSpeed global): normal (the default), slow, fast, or
|
|
50
51
|
;; until the player clicks.
|
|
@@ -53,6 +54,14 @@
|
|
|
53
54
|
(define TEXT_FAST 2)
|
|
54
55
|
(define TEXT_CLICK 3)
|
|
55
56
|
|
|
57
|
+
;; Lines with a recording (the speech global): heard and read (the default), read only, or
|
|
58
|
+
;; heard only. Lines with no recording are always shown.
|
|
59
|
+
(define SPEECH_BOTH 0)
|
|
60
|
+
(define SPEECH_TEXT 1)
|
|
61
|
+
(define SPEECH_VOICE 2)
|
|
62
|
+
;; Cycles a spoken line stays after its recording ends.
|
|
63
|
+
(define VOICE_PAUSE 20)
|
|
64
|
+
|
|
56
65
|
;; Event types, as GetEvent reports them.
|
|
57
66
|
(define EV_NULL 0)
|
|
58
67
|
(define EV_MOUSE_DOWN 1)
|
package/package.json
CHANGED
|
@@ -104,7 +104,7 @@
|
|
|
104
104
|
"frontY",
|
|
105
105
|
"backY"
|
|
106
106
|
],
|
|
107
|
-
"description": "Perspective: front% at y frontY, back% at y backY",
|
|
107
|
+
"description": "Perspective for the hero alone: front% at y frontY, back% at y backY (perspective: is simpler)",
|
|
108
108
|
"properties": {
|
|
109
109
|
"front": {
|
|
110
110
|
"type": "integer"
|
|
@@ -122,6 +122,25 @@
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
},
|
|
125
|
+
"perspective": {
|
|
126
|
+
"type": "object",
|
|
127
|
+
"additionalProperties": false,
|
|
128
|
+
"required": [
|
|
129
|
+
"horizon",
|
|
130
|
+
"fullSize"
|
|
131
|
+
],
|
|
132
|
+
"description": "The picture's camera: people are sized by how far below the horizon they stand. The hero and props that move are sized as they go.",
|
|
133
|
+
"properties": {
|
|
134
|
+
"horizon": {
|
|
135
|
+
"type": "integer",
|
|
136
|
+
"description": "The y the floor's lines meet at"
|
|
137
|
+
},
|
|
138
|
+
"fullSize": {
|
|
139
|
+
"type": "integer",
|
|
140
|
+
"description": "The y of a figure's feet where it's drawn at full size"
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
},
|
|
125
144
|
"walkable": {
|
|
126
145
|
"$ref": "#/$defs/polygon",
|
|
127
146
|
"description": "The floor: the hero stays inside"
|
|
@@ -227,6 +246,10 @@
|
|
|
227
246
|
"type": "string",
|
|
228
247
|
"pattern": "^[A-Z][A-Za-z0-9]*$",
|
|
229
248
|
"description": "A Script class of the game's: an instance of it runs on the prop, e.g. an ambient behaviour"
|
|
249
|
+
},
|
|
250
|
+
"scale": {
|
|
251
|
+
"type": "boolean",
|
|
252
|
+
"description": "Sized by the room's perspective (default: if it moves). A still prop is sized once, for where it is."
|
|
230
253
|
}
|
|
231
254
|
}
|
|
232
255
|
}
|
|
@@ -4,7 +4,8 @@ import type { Target } from "./target.ts";
|
|
|
4
4
|
* (.sca) and message text (.msg), the formats tools/mod-build.ts already builds from.
|
|
5
5
|
*
|
|
6
6
|
* room: 701
|
|
7
|
-
* hero: { at: [36, 152], enterTo: [110, 160]
|
|
7
|
+
* hero: { at: [36, 152], enterTo: [110, 160] }
|
|
8
|
+
* perspective: { horizon: 72, fullSize: 176 }
|
|
8
9
|
* walkable: [[28, 152], [60, 135], ...]
|
|
9
10
|
* features:
|
|
10
11
|
* pods: { rect: [236, 14, 310, 94] }
|
|
@@ -28,7 +29,7 @@ export interface RoomSpec {
|
|
|
28
29
|
at?: Point;
|
|
29
30
|
/** Walk here on arrival (hands off until there). */
|
|
30
31
|
enterTo?: Point;
|
|
31
|
-
/** Perspective: `front`% at y `frontY`, `back`% at y `backY
|
|
32
|
+
/** Perspective for the hero alone: `front`% at y `frontY`, `back`% at y `backY` (perspective: is simpler). */
|
|
32
33
|
scale?: {
|
|
33
34
|
front: number;
|
|
34
35
|
back: number;
|
|
@@ -36,6 +37,17 @@ export interface RoomSpec {
|
|
|
36
37
|
backY: number;
|
|
37
38
|
};
|
|
38
39
|
};
|
|
40
|
+
/**
|
|
41
|
+
* The picture's camera, for sizing people by where they stand: the horizon (the y the
|
|
42
|
+
* floor's lines meet at), and the y where a figure's feet are when it's drawn at full
|
|
43
|
+
* size. A figure's height is in proportion to how far below the horizon it stands, so it
|
|
44
|
+
* shrinks towards the back and grows past fullSize at the front. The hero and props that
|
|
45
|
+
* move are sized as they go; a still prop only with scale: true.
|
|
46
|
+
*/
|
|
47
|
+
perspective?: {
|
|
48
|
+
horizon: number;
|
|
49
|
+
fullSize: number;
|
|
50
|
+
};
|
|
39
51
|
/** The floor: a polygon the hero stays inside. */
|
|
40
52
|
walkable?: Point[];
|
|
41
53
|
/** Polygons the hero walks around. */
|
|
@@ -84,6 +96,11 @@ interface PropSpec {
|
|
|
84
96
|
sightAngle?: number;
|
|
85
97
|
/** A Script class of the game's: an instance of it runs on the prop (ambient behaviour). */
|
|
86
98
|
script?: string;
|
|
99
|
+
/**
|
|
100
|
+
* Sized by the room's perspective: by default a prop that moves is and a still one
|
|
101
|
+
* (drawn to fit its place, like someone in a chair) isn't.
|
|
102
|
+
*/
|
|
103
|
+
scale?: boolean;
|
|
87
104
|
}
|
|
88
105
|
export interface CompiledRoom {
|
|
89
106
|
number: number;
|
|
@@ -95,6 +112,32 @@ export interface CompiledRoom {
|
|
|
95
112
|
roomTalkers: boolean;
|
|
96
113
|
/** Yarn variables ($name) used: each is a flag, numbered by `options.flag`. */
|
|
97
114
|
flags: string[];
|
|
115
|
+
/** The spoken lines (not menu labels), with their message keys today. */
|
|
116
|
+
lines: SpokenLine[];
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* A line someone says. Its message key (noun, verb, cond, seq) is positional and changes
|
|
120
|
+
* when lines or things are added before it; its id (#line:) doesn't, so recordings use that.
|
|
121
|
+
*/
|
|
122
|
+
export interface SpokenLine {
|
|
123
|
+
id?: string;
|
|
124
|
+
noun: number;
|
|
125
|
+
verb: number;
|
|
126
|
+
cond: number;
|
|
127
|
+
seq: number;
|
|
128
|
+
talker: number;
|
|
129
|
+
/** As written in the Yarn (Narrator when it has none). */
|
|
130
|
+
speaker: string;
|
|
131
|
+
text: string;
|
|
132
|
+
/** The Yarn node it's in (and for a topic's answer, the topic). */
|
|
133
|
+
node: string;
|
|
134
|
+
/** What's said just before it in the same node, if anything: context for whoever records it. */
|
|
135
|
+
before?: {
|
|
136
|
+
speaker: string;
|
|
137
|
+
text: string;
|
|
138
|
+
};
|
|
139
|
+
/** file:line in the Yarn. */
|
|
140
|
+
where: string;
|
|
98
141
|
}
|
|
99
142
|
export interface CompileOptions {
|
|
100
143
|
/**
|
|
@@ -42,7 +42,7 @@ class SpecChecker {
|
|
|
42
42
|
const s = this.spec;
|
|
43
43
|
if (!s || typeof s !== "object")
|
|
44
44
|
this.fail([], "expected a mapping (room: ..., features: ...)");
|
|
45
|
-
this.known(s, [], ["room", "picture", "music", "hero", "walkable", "obstacles", "features", "exits", "props", "characters", "properties"]);
|
|
45
|
+
this.known(s, [], ["room", "picture", "music", "hero", "perspective", "walkable", "obstacles", "features", "exits", "props", "characters", "properties"]);
|
|
46
46
|
this.int(s.room, ["room"], 0, 65535);
|
|
47
47
|
if (s.picture !== undefined)
|
|
48
48
|
this.int(s.picture, ["picture"], 0, 65535);
|
|
@@ -62,6 +62,14 @@ class SpecChecker {
|
|
|
62
62
|
this.int(sc[k], ["hero", "scale", k], 0, 1000);
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
|
+
if (s.perspective !== undefined) {
|
|
66
|
+
const pv = this.map(s.perspective, ["perspective"]);
|
|
67
|
+
this.known(pv, ["perspective"], ["horizon", "fullSize"]);
|
|
68
|
+
this.int(pv.horizon, ["perspective", "horizon"], -1000, 199);
|
|
69
|
+
this.int(pv.fullSize, ["perspective", "fullSize"], 0, 1000);
|
|
70
|
+
if (pv.fullSize <= pv.horizon)
|
|
71
|
+
this.fail(["perspective", "fullSize"], "must be below the horizon (a bigger y)");
|
|
72
|
+
}
|
|
65
73
|
if (s.walkable !== undefined)
|
|
66
74
|
this.polygon(s.walkable, ["walkable"]);
|
|
67
75
|
if (s.obstacles !== undefined) {
|
|
@@ -83,7 +91,11 @@ class SpecChecker {
|
|
|
83
91
|
names.add(name);
|
|
84
92
|
const t = this.map(value, path);
|
|
85
93
|
if (group === "props") {
|
|
86
|
-
this.known(t, path, ["view", "at", "loop", "cel", "cycle", "sightAngle", "moves", "script"]);
|
|
94
|
+
this.known(t, path, ["view", "at", "loop", "cel", "cycle", "sightAngle", "moves", "script", "scale"]);
|
|
95
|
+
if (t.scale !== undefined && typeof t.scale !== "boolean")
|
|
96
|
+
this.fail([...path, "scale"], "expected true or false");
|
|
97
|
+
if (t.scale === true && s.perspective === undefined)
|
|
98
|
+
this.fail([...path, "scale"], "the room has no perspective: to size it by");
|
|
87
99
|
if (t.script !== undefined && (typeof t.script !== "string" || !/^[A-Z][A-Za-z0-9]*$/.test(t.script)))
|
|
88
100
|
this.fail([...path, "script"], "expected a class name, e.g. Scurry");
|
|
89
101
|
if (t.moves !== undefined && typeof t.moves !== "boolean")
|
|
@@ -175,6 +187,7 @@ class SpecChecker {
|
|
|
175
187
|
v.forEach((p, i) => this.point(p, [...path, i]));
|
|
176
188
|
}
|
|
177
189
|
}
|
|
190
|
+
const SCREEN_BOTTOM = 200;
|
|
178
191
|
const num = (n) => (n < 0 ? `-$${(-n).toString(16)}` : String(n));
|
|
179
192
|
function pushVal(v) {
|
|
180
193
|
if (typeof v === "number")
|
|
@@ -217,6 +230,7 @@ class RoomCompiler {
|
|
|
217
230
|
name;
|
|
218
231
|
nouns = new Map();
|
|
219
232
|
messages = [];
|
|
233
|
+
lines = [];
|
|
220
234
|
instances = [];
|
|
221
235
|
code = [];
|
|
222
236
|
strings = [];
|
|
@@ -295,7 +309,7 @@ class RoomCompiler {
|
|
|
295
309
|
this.teller(conv);
|
|
296
310
|
return {
|
|
297
311
|
number: spec.room, sca: this.assembly(), msg: this.messageText(), roomTalkers: characters.length > 0,
|
|
298
|
-
flags: [...this.flagsUsed].sort(),
|
|
312
|
+
flags: [...this.flagsUsed].sort(), lines: this.lines,
|
|
299
313
|
};
|
|
300
314
|
}
|
|
301
315
|
/** Yarn nodes -> messages and scripts; returns the arrival narration's steps. */
|
|
@@ -373,7 +387,7 @@ class RoomCompiler {
|
|
|
373
387
|
fail(`choice "${c.text}" needs at least one line under it`, c.line);
|
|
374
388
|
const topic = k + 1;
|
|
375
389
|
const label = `${node.title}: ${c.text}`;
|
|
376
|
-
this.message(conv.rootNoun, tv, topic, 1, { kind: "line", speaker: "Hero", text: c.text, line: c.line }, label);
|
|
390
|
+
this.message(conv.rootNoun, tv, topic, 1, { kind: "line", speaker: "Hero", text: c.text, line: c.line }, label, false);
|
|
377
391
|
if (c.when)
|
|
378
392
|
conv.conditional.push({ topic, when: c.when, where: `${this.yarnFile}:${c.line}` });
|
|
379
393
|
if (c.body.every((x) => x.kind === "line")) {
|
|
@@ -692,9 +706,21 @@ class RoomCompiler {
|
|
|
692
706
|
return [...this.expr(value, where), `bnt ${clear}`, ...this.flagCall("set", flag), `jmp ${done}`, `${clear}:`, ...this.flagCall("clear", flag), `${done}:`];
|
|
693
707
|
}
|
|
694
708
|
// --- Objects ---
|
|
695
|
-
message
|
|
709
|
+
/** A message; `spoken` is false for a topic menu's labels, which nobody says. */
|
|
710
|
+
message(noun, verb, cond, seq, l, label, spoken = true) {
|
|
696
711
|
const where = `${this.yarnFile}:${l.line}`;
|
|
697
|
-
|
|
712
|
+
const prev = this.messages.at(-1);
|
|
713
|
+
const m = { noun, verb, cond, seq, talker: this.talkerOf(l.speaker, where), speaker: l.speaker ?? "Narrator", text: plainText(l.text, where), label };
|
|
714
|
+
this.messages.push(m);
|
|
715
|
+
if (!spoken)
|
|
716
|
+
return;
|
|
717
|
+
const twice = l.id !== undefined && this.lines.find((x) => x.id === l.id);
|
|
718
|
+
if (twice)
|
|
719
|
+
throw new ContentError(`${where}: #line:${l.id} is already the id of ${twice.where}`);
|
|
720
|
+
this.lines.push({
|
|
721
|
+
...(l.id === undefined ? {} : { id: l.id }), noun, verb, cond, seq, talker: m.talker, speaker: m.speaker, text: m.text, node: label,
|
|
722
|
+
...(prev?.label === label ? { before: { speaker: prev.speaker, text: prev.text } } : {}), where,
|
|
723
|
+
});
|
|
698
724
|
}
|
|
699
725
|
talkerOf(speaker, where) {
|
|
700
726
|
if (speaker === undefined)
|
|
@@ -730,8 +756,14 @@ class RoomCompiler {
|
|
|
730
756
|
if (hero?.at)
|
|
731
757
|
egoCalls.push(["x", [hero.at[0]]], ["y", [hero.at[1]]]);
|
|
732
758
|
egoCalls.push(["normalize", []]);
|
|
759
|
+
// Each room sizes the hero its own way, or not at all.
|
|
760
|
+
const perspective = this.perspective();
|
|
733
761
|
if (hero?.scale)
|
|
734
762
|
egoCalls.push(["setScaler", [{ cls: "Scaler" }, hero.scale.front, hero.scale.back, hero.scale.frontY, hero.scale.backY]]);
|
|
763
|
+
else if (perspective)
|
|
764
|
+
egoCalls.push(["setScaler", [{ cls: "Scaler" }, ...perspective]]);
|
|
765
|
+
else
|
|
766
|
+
egoCalls.push(["setScaler", [0]]);
|
|
735
767
|
c.push(...send({ global: target.globals.ego }, egoCalls));
|
|
736
768
|
if (spec.music !== undefined)
|
|
737
769
|
c.push(...this.playMusic(spec.music));
|
|
@@ -740,6 +772,7 @@ class RoomCompiler {
|
|
|
740
772
|
for (const [name, p] of Object.entries(spec.props ?? {})) {
|
|
741
773
|
c.push(...send({ obj: name }, [
|
|
742
774
|
["init", []],
|
|
775
|
+
...(perspective && p.moves && p.scale !== false ? [["setScaler", [{ cls: "Scaler" }, ...perspective]]] : []),
|
|
743
776
|
...(p.cycle === "forward" ? [["setCycle", [{ cls: this.target.forwardCycle }]]] : []),
|
|
744
777
|
// A new instance of the game's Script class, running on the prop.
|
|
745
778
|
...(p.script ? [["setScript", [{ code: ["pushi #new", "push0", `class ${p.script}`, "send 4"] }]]] : []),
|
|
@@ -856,9 +889,38 @@ class RoomCompiler {
|
|
|
856
889
|
if (cases.length)
|
|
857
890
|
this.doVerb(name, "Feature", cases);
|
|
858
891
|
}
|
|
892
|
+
/**
|
|
893
|
+
* The room's perspective as Scaler arguments (front, back, frontY, backY): sizes at the
|
|
894
|
+
* nearest and farthest places anyone stands (the floor, and where the hero and props that
|
|
895
|
+
* move start). Size is a straight line in y, so the Scaler's in-between is exact; keeping
|
|
896
|
+
* to that range keeps its 16-bit arithmetic from overflowing.
|
|
897
|
+
*/
|
|
898
|
+
perspective() {
|
|
899
|
+
const pv = this.spec.perspective;
|
|
900
|
+
if (!pv)
|
|
901
|
+
return undefined;
|
|
902
|
+
const ys = [
|
|
903
|
+
...(this.spec.walkable ?? []).map((p) => p[1]),
|
|
904
|
+
...Object.values(this.spec.props ?? {}).filter((p) => p.moves).map((p) => p.at[1]),
|
|
905
|
+
...[this.spec.hero?.at, this.spec.hero?.enterTo].flatMap((p) => (p ? [p[1]] : [])),
|
|
906
|
+
].filter((y) => y > pv.horizon);
|
|
907
|
+
const backY = ys.length ? Math.min(...ys) : pv.horizon + 1, frontY = ys.length ? Math.max(...ys) : SCREEN_BOTTOM - 1;
|
|
908
|
+
const size = (y) => Math.round(((y - pv.horizon) / (pv.fullSize - pv.horizon)) * 100);
|
|
909
|
+
const [front, back] = [size(frontY), size(backY)];
|
|
910
|
+
// Scaler works out (front - back) * (y - backY) on the way.
|
|
911
|
+
if ((front - back) * (frontY - backY) > 32767)
|
|
912
|
+
throw new ContentError(`room ${this.spec.room}: perspective: sizes from ${back}% to ${front}% over ${frontY - backY} lines are too much for the Scaler`);
|
|
913
|
+
return [front, back, Math.max(frontY, backY + 1), backY];
|
|
914
|
+
}
|
|
859
915
|
prop(name, p) {
|
|
860
916
|
const cases = this.scriptedVerbs.get(name) ?? [];
|
|
861
|
-
|
|
917
|
+
// A still prop is sized once, for where it is.
|
|
918
|
+
const pv = this.spec.perspective;
|
|
919
|
+
const fixed = pv && p.scale === true && !p.moves ? Math.round(((p.at[1] - pv.horizon) / (pv.fullSize - pv.horizon)) * 128) : undefined;
|
|
920
|
+
this.instance(name, p.moves ? "Actor" : "Prop", {
|
|
921
|
+
noun: this.nouns.get(name), modNum: -1, sightAngle: p.sightAngle ?? 180, x: p.at[0], y: p.at[1], view: p.view, loop: p.loop ?? 0, cel: p.cel ?? 0,
|
|
922
|
+
...(fixed === undefined ? {} : { scaleSignal: 1, scaleX: fixed, scaleY: fixed }),
|
|
923
|
+
}, cases.length ? ["doVerb"] : []);
|
|
862
924
|
if (cases.length)
|
|
863
925
|
this.doVerb(name, p.moves ? "Actor" : "Prop", cases);
|
|
864
926
|
}
|