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 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
@@ -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
- return new Uint8Array(await res.arrayBuffer());
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)
@@ -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
- return new Uint8Array(await res.arrayBuffer());
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
- add: 0 "Carry on"
63
- show: self))
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
- (= box
115
- ((TextItem new:)
116
- text: txt
117
- font: font
118
- width: width
119
- x: (if (== x -1) (/ (- SCREEN_WIDTH width) 2) else x)
120
- y: y
121
- yourself:))
122
- (box init:)
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 box (!= readFor -1) (>= (- gameTime shownAt) readFor)) (self done:)))
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 box
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
- ;; About as long as it takes to say: half a second, and two cycles a letter.
204
- (= mouthFor (+ 30 (* 2 (String STRING_LENGTH txt))))))
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
- ((self findTalker: talker) say: buffer self)
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 in boxes, one under another; the first click on one ends it. While it's open it
277
- ;; gets every click (it's the `dialog`). The caller hears the choice by `choose: value`.
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
- y 20
283
- width 240)
284
-
285
- (method (add v txt &tmp item)
286
- (if (not items) (= items (List new:)))
287
- (= item ((MenuItem new:) value: v text: txt width: width x: (/ (- SCREEN_WIDTH width) 2) y: y yourself:))
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
- (+= y (+ (item height?) 2))
329
+ (+= next (item height?))
290
330
  (items add: item)
291
331
  (return self))
292
332
 
293
- (method (show whoCares)
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) ; how long a line stays up: TEXT_NORMAL, TEXT_SLOW, TEXT_FAST, TEXT_CLICK
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sci2-ts",
3
- "version": "0.9.0",
3
+ "version": "0.11.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",
@@ -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], scale: { front: 100, back: 70, frontY: 186, backY: 122 } }
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(noun, verb, cond, seq, l, label) {
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
- this.messages.push({ noun, verb, cond, seq, talker: this.talkerOf(l.speaker, where), text: plainText(l.text, where), label });
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
- this.instance(name, p.moves ? "Actor" : "Prop", { 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 }, cases.length ? ["doVerb"] : []);
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
  }