sci2-ts 0.8.2 → 0.10.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.10.0](https://github.com/rlueder/sci-ts/compare/v0.9.0...v0.10.0) (2026-10-02)
8
+
9
+ ### Added
10
+
11
+ * 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)
12
+
13
+ ## [0.9.0](https://github.com/rlueder/sci-ts/compare/v0.8.2...v0.9.0) (2026-10-02)
14
+
15
+ ### Added
16
+
17
+ * **game:** redesign hello demo with flat polygon art ([#11](https://github.com/rlueder/sci-ts/issues/11)) ([0ab23aa](https://github.com/rlueder/sci-ts/commit/0ab23aacd7e604a90ea9b309cbbe6c871c5a70bd))
18
+
7
19
  ## [0.8.2](https://github.com/rlueder/sci-ts/compare/v0.8.1...v0.8.2) (2026-10-02)
8
20
 
9
21
  ### Fixed
package/README.md CHANGED
@@ -41,7 +41,7 @@ or exported from a paint program. `sci-ts build` turns it into the `RESOURCE.MAP
41
41
  (super doVerb: verb)))
42
42
  ```
43
43
 
44
- ![games/hello: the hero on the hill, looking at the moon](docs/screenshots/hello.png)
44
+ ![games/hello: the hero beside the lantern on the moonlit hill](docs/screenshots/hello.png)
45
45
 
46
46
  | | |
47
47
  |---|---|
@@ -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
@@ -96,12 +96,19 @@
96
96
  ;; Shows a line until it's been there long enough to read (as long as `textSpeed` says), or
97
97
  ;; the player clicks or presses Enter, Space or "."; then cues whoever asked. The narrator
98
98
  ;; speaks for no one in particular.
99
+ ;;
100
+ ;; A line from a message file (the Messager sets module, noun, verb, cond and seq before
101
+ ;; say:) is heard too if it has a recording, unless `speech` is SPEECH_TEXT. A heard line
102
+ ;; stays until its recording ends, and a moment more; with SPEECH_VOICE it isn't shown.
99
103
  (class Narrator of Obj
100
104
  (properties
101
105
  caller 0
102
106
  box 0
103
- shownAt 0 ; the game time it went up
104
- readFor 0 ; how long it stays (-1: until dismissed)
107
+ shownAt 0 ; the game time it went up (or its recording ended)
108
+ readFor 0 ; how long it stays (-1: until dismissed, or while it's heard)
109
+ voiced 0 ; its recording is playing
110
+ module 0 ; the message the next say: is, if it's one (set by the Messager)
111
+ noun 0 verb 0 cond 0 seq 0
105
112
  font -1 ; -1: the textStyle's
106
113
  x -1 ; -1: centred
107
114
  y 16
@@ -111,18 +118,22 @@
111
118
  (self clear:)
112
119
  (if (and talking (!= talking self)) (talking clear:))
113
120
  (= 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:)
121
+ (if (and module (!= speech SPEECH_TEXT))
122
+ (= voiced (!= 0 (DoAudio 2 module noun verb cond seq))))
123
+ (= module 0)
124
+ (if (or (not voiced) (!= speech SPEECH_VOICE))
125
+ (= box
126
+ ((TextItem new:)
127
+ text: txt
128
+ font: font
129
+ width: width
130
+ x: (if (== x -1) (/ (- SCREEN_WIDTH width) 2) else x)
131
+ y: y
132
+ yourself:))
133
+ (box init:))
123
134
  (= talking self)
124
135
  (= shownAt gameTime)
125
- (= readFor (self readingTime: (String STRING_LENGTH txt))))
136
+ (= readFor (if voiced -1 else (self readingTime: (String STRING_LENGTH txt)))))
126
137
 
127
138
  ;; Cycles to read a line of n characters: normally two seconds, and more for longer lines;
128
139
  ;; -1 is until the player dismisses it.
@@ -134,12 +145,18 @@
134
145
  (else (return (+ 120 (* 3 n))))))
135
146
 
136
147
  ;; Time is measured as cycles since, which stays right when gameTime wraps past 32767.
148
+ ;; When a heard line's recording ends, it stays a moment (or until a click, if the text
149
+ ;; is up and that's the text speed).
137
150
  (method (doit)
138
- (if (and box (!= readFor -1) (>= (- gameTime shownAt) readFor)) (self done:)))
151
+ (if (and voiced (== (DoAudio 6) -1))
152
+ (= voiced 0)
153
+ (= shownAt gameTime)
154
+ (= readFor (if (and box (== textSpeed TEXT_CLICK)) -1 else VOICE_PAUSE)))
155
+ (if (and (== talking self) (!= readFor -1) (>= (- gameTime shownAt) readFor)) (self done:)))
139
156
 
140
157
  ;; A click, Enter, Space or "." while a line is up dismisses it (and nothing else).
141
158
  (method (handleEvent event)
142
- (if (and box
159
+ (if (and (== talking self)
143
160
  (or (== (event type?) EV_MOUSE_DOWN)
144
161
  (and (== (event type?) EV_KEY_DOWN)
145
162
  (or (== (event message?) KEY_ENTER) (== (event message?) KEY_SPACE) (== (event message?) KEY_PERIOD)))))
@@ -149,6 +166,7 @@
149
166
  (return FALSE))
150
167
 
151
168
  (method (clear)
169
+ (if voiced (DoAudio 3) (= voiced 0))
152
170
  (if box (box dispose:) (= box 0))
153
171
  (if (== talking self) (= talking 0)))
154
172
 
@@ -200,11 +218,12 @@
200
218
  (if eyes (eyes setCycle: Blink))
201
219
  (if mouth
202
220
  (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))))))
221
+ ;; While it's heard; or about as long as it takes to say: half a second, and two
222
+ ;; cycles a letter.
223
+ (= mouthFor (if voiced 0 else (+ 30 (* 2 (String STRING_LENGTH txt)))))))
205
224
 
206
225
  (method (doit)
207
- (if (and mouth (mouth cycler?) (>= (- gameTime shownAt) mouthFor))
226
+ (if (and mouth (mouth cycler?) (not voiced) (>= (- gameTime shownAt) mouthFor))
208
227
  (mouth setCycle: 0 setCel: 0))
209
228
  (super doit:))
210
229
 
@@ -221,7 +240,8 @@
221
240
  (super clear:)))
222
241
 
223
242
  ;; 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.
243
+ ;; (messager say: noun verb [cond [seq [caller [module]]]]). Each line goes to its talker,
244
+ ;; told which message it is so it can play the line's recording.
225
245
  ;; Returns whether there was anything to say; cues the caller after the last line.
226
246
  (class Messager of Obj
227
247
  (properties
@@ -245,9 +265,10 @@
245
265
  (method (sayNext &tmp c talker)
246
266
  (if (not buffer) (= buffer (String ARRAY_NEW 400)))
247
267
  (if (Message 2 module noun verb cond seq)
248
- (= talker (Message 0 module noun verb cond seq buffer))
268
+ (= talker (self findTalker: (Message 0 module noun verb cond seq buffer)))
269
+ (talker module: module noun: noun verb: verb cond: cond seq: seq)
249
270
  (++ seq)
250
- ((self findTalker: talker) say: buffer self)
271
+ (talker say: buffer self)
251
272
  else
252
273
  (= c caller)
253
274
  (= caller 0)
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.8.2",
3
+ "version": "0.10.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
  }