sci2-ts 0.11.0 → 0.13.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.13.0](https://github.com/rlueder/sci-ts/compare/v0.12.0...v0.13.0) (2026-10-02)
8
+
9
+ ### Added
10
+
11
+ * **content:** perspective measured across the floor, for rooms painted at an angle ([#15](https://github.com/rlueder/sci-ts/issues/15)) ([8020031](https://github.com/rlueder/sci-ts/commit/8020031785b75c966ba449c7bcbe159002868fec))
12
+
13
+ ## [0.12.0](https://github.com/rlueder/sci-ts/compare/v0.11.0...v0.12.0) (2026-10-02)
14
+
15
+ ### Added
16
+
17
+ * **lib:** portraits that face the middle from their character's side, framed, and one for the hero ([#14](https://github.com/rlueder/sci-ts/issues/14)) ([e7be055](https://github.com/rlueder/sci-ts/commit/e7be0555af2c49743c539e7231cf979ac274617e))
18
+
7
19
  ## [0.11.0](https://github.com/rlueder/sci-ts/compare/v0.10.0...v0.11.0) (2026-10-02)
8
20
 
9
21
  ### Added
package/lib/996.sc CHANGED
@@ -17,7 +17,10 @@
17
17
  back 255 ; paper
18
18
  border 0 ; a one-pixel border's colour; -1: none
19
19
  frame -1 ; a frame view, or -1
20
- margin 4)) ; between the text and the border or frame
20
+ margin 4 ; between the text and the border or frame
21
+ portraitFrame -1 ; a view around portraits: loop 0 behind the face, loop 1 over its edges
22
+ portraitX 8 ; where a portrait's face goes on the left (mirrored on the right)
23
+ portraitY 8))
21
24
 
22
25
  ;; A box of text over the room: drawn by the interpreter into a bitmap, shown as a screen
23
26
  ;; item in the UI plane. Set text (and width, x, y), then init; it's as tall as the text
@@ -191,34 +194,80 @@
191
194
  (super say: line &rest whoCares)))
192
195
 
193
196
  ;; Someone with a portrait: a view whose loop 0 is the bust, loop 1 the mouth and loop 2 the
194
- ;; eyes, every cel the same size and anchored at its top-left corner. The room's talker hands
195
- ;; its parts to init: mouth bust eyes frame, where frame is drawn first (the bust), placed
196
- ;; where the portrait goes (the top left of the screen). The text goes to its right. While
197
- ;; the line is said the mouth moves, then closes; the eyes blink.
197
+ ;; eyes, every cel the same size and anchored at its top-left corner; loops 3 to 5, if there
198
+ ;; are any, are the same facing left. A room's talker is handed its parts (init: mouth bust
199
+ ;; eyes frame, where frame is drawn as the bust); one with a view of its own (the hero's)
200
+ ;; makes them. While the line is said the mouth moves, then closes; the eyes blink.
201
+ ;;
202
+ ;; The portrait goes at the top left facing right, or the top right facing left, with the
203
+ ;; text beside it on the side towards the middle. A character takes the side of the hero
204
+ ;; it stands on (who: is its body in the room; without one, the right); the hero takes the
205
+ ;; side opposite whoever spoke last. The textStyle places it (portraitX, portraitY) and can
206
+ ;; frame it (portraitFrame).
198
207
  (class PortraitTalker of Talker
199
208
  (properties
200
209
  mouth 0 bust 0 eyes 0 frame 0
210
+ view -1 ; the portrait's view, for a talker that makes its own parts
211
+ who 0 ; the character's body in the room
212
+ side 0 ; where the portrait is: 0 the left, 1 the right
213
+ otherSide 1 ; the hero's: the side of whoever spoke last
214
+ back 0 front 0 ; the portraitFrame, behind the face and over its edges
201
215
  priority 150
202
216
  mouthFor 0) ; cycles the mouth moves for
203
217
 
204
218
  (method (init theMouth theBust theEyes theFrame)
205
- (= mouth theMouth)
206
- (= bust theBust)
207
- (= eyes theEyes)
208
- (= frame theFrame))
209
-
210
- (method (say txt whoCares &tmp portrait)
211
- (self init:)
212
- (= portrait (if frame frame else bust))
213
- (if portrait
214
- (= x (+ (portrait x?) (CelWide (portrait view?) (portrait loop?) (portrait cel?)) 6))
215
- (= y (portrait y?))
216
- (= width (- SCREEN_WIDTH (+ x 6))))
219
+ (if argc
220
+ (= mouth theMouth)
221
+ (= bust theBust)
222
+ (= eyes theEyes)
223
+ (= frame theFrame)))
224
+
225
+ (method (makeParts)
226
+ (if (and (!= view -1) (not bust) (not frame))
227
+ (= bust ((View new:) view: view yourself:))
228
+ (= mouth ((Prop new:) view: view yourself:))
229
+ (= eyes ((Prop new:) view: view yourself:))))
230
+
231
+ ;; This line's side: see above.
232
+ (method (pickSide &tmp s)
233
+ (if (== self heroTalker) (return (- 1 otherSide)))
234
+ (= s (if (and who ego (< (who x?) (ego x?))) 0 else 1))
235
+ (if (and heroTalker (!= heroTalker self) (heroTalker respondsTo: #otherSide)) (heroTalker otherSide: s))
236
+ (return s))
237
+
238
+ (method (say txt whoCares &tmp face l pw f fx fy over)
239
+ (self init: makeParts:)
240
+ (= face (if frame frame else bust))
241
+ (if face
242
+ (= side (self pickSide:))
243
+ (= l (if (and side (>= (NumLoops face) 6)) 3 else 0))
244
+ (= pw (CelWide (face view?) l 0))
245
+ (= f (textStyle portraitFrame?))
246
+ (= over (if (!= f -1) (/ (- (CelWide f 0 0) pw) 2) else 0))
247
+ (= fx (if side (- SCREEN_WIDTH (+ (textStyle portraitX?) pw)) else (textStyle portraitX?)))
248
+ (= fy (textStyle portraitY?))
249
+ (face x: fx y: fy loop: l cel: 0)
250
+ (if mouth (mouth x: fx y: fy loop: (+ l 1) cel: 0))
251
+ (if eyes (eyes x: fx y: fy loop: (+ l 2) cel: 0))
252
+ (if (!= f -1)
253
+ (if (not back) (= back ((View new:) view: f loop: 0 cel: 0 yourself:)))
254
+ (back x: fx y: fy)
255
+ (if (and (not front) (>= (NumLoops back) 2)) (= front ((View new:) view: f loop: 1 cel: 0 yourself:)))
256
+ (if front (front x: fx y: fy)))
257
+ ;; The text beside it, towards the middle.
258
+ (= y fy)
259
+ (if side
260
+ (= x 6)
261
+ (= width (- fx (+ over 12)))
262
+ else
263
+ (= x (+ fx pw over 6))
264
+ (= width (- SCREEN_WIDTH (+ x 6)))))
217
265
  (super say: txt &rest whoCares)
218
- (self showPart: frame priority)
219
- (self showPart: bust priority)
220
- (self showPart: eyes (+ priority 1))
266
+ (self showPart: back (- priority 1))
267
+ (self showPart: face priority)
221
268
  (self showPart: mouth (+ priority 1))
269
+ (self showPart: eyes (+ priority 1))
270
+ (self showPart: front (+ priority 2))
222
271
  (if eyes (eyes setCycle: Blink))
223
272
  (if mouth
224
273
  (mouth setCycle: Forward)
@@ -236,11 +285,19 @@
236
285
  (part init:)
237
286
  (part plane: uiPlane setPri: pri)))
238
287
 
288
+ ;; Off the screen, kept for the next line.
289
+ (method (hidePart part)
290
+ (if part
291
+ (DeleteScreenItem part)
292
+ (cast delete: part)))
293
+
239
294
  (method (clear)
240
- (if frame (frame dispose:))
241
- (if bust (bust dispose:))
242
- (if eyes (eyes dispose:))
243
- (if mouth (mouth dispose:))
295
+ (self hidePart: front)
296
+ (self hidePart: back)
297
+ (self hidePart: frame)
298
+ (self hidePart: bust)
299
+ (self hidePart: eyes)
300
+ (self hidePart: mouth)
244
301
  (super clear:)))
245
302
 
246
303
  ;; Says the lines of a message file for a noun, verb and condition, in sequence:
package/lib/997.sc CHANGED
@@ -224,3 +224,43 @@
224
224
  scaleX: (/ (* pct 128) 100)
225
225
  scaleY: (/ (* pct 128) 100)
226
226
  scaleSignal: (| (client scaleSignal?) SCALE_ON))))
227
+
228
+ ;; Sizes its client on a floor seen at an angle, where one line across the screen isn't the
229
+ ;; same depth all the way along. Sizes are measured at the back and front of the floor at
230
+ ;; three places across it (left, middle, right): at each of those, size changes in a straight
231
+ ;; line with y, as perspective does; between them, in a straight line with x.
232
+ ;; (actor setScaler: FloorScaler x1 backY1 back1 frontY1 front1 x2 ... x3 ...)
233
+ (class FloorScaler of Scaler
234
+ (properties
235
+ x1 0 yb1 0 sb1 100 yf1 1 sf1 100
236
+ x2 1 yb2 0 sb2 100 yf2 1 sf2 100
237
+ x3 2 yb3 0 sb3 100 yf3 1 sf3 100)
238
+
239
+ (method (init who a1 b1 c1 d1 e1 a2 b2 c2 d2 e2 a3 b3 c3 d3 e3)
240
+ (= client who)
241
+ (= x1 a1) (= yb1 b1) (= sb1 c1) (= yf1 d1) (= sf1 e1)
242
+ (= x2 a2) (= yb2 b2) (= sb2 c2) (= yf2 d2) (= sf2 e2)
243
+ (= x3 a3) (= yb3 b3) (= sb3 c3) (= yf3 d3) (= sf3 e3)
244
+ (self doit:))
245
+
246
+ ;; The size at y where one place was measured: the straight line through its back and front.
247
+ (method (along y yb sb yf sf)
248
+ (return (+ sb (/ (* (- sf sb) (- y yb)) (- yf yb)))))
249
+
250
+ (method (doit &tmp x y a b c pct)
251
+ (= x (client x?))
252
+ (= y (client y?))
253
+ (= a (self along: y yb1 sb1 yf1 sf1))
254
+ (= b (self along: y yb2 sb2 yf2 sf2))
255
+ (= c (self along: y yb3 sb3 yf3 sf3))
256
+ (= pct
257
+ (cond
258
+ ((<= x x1) a)
259
+ ((< x x2) (+ a (/ (* (- b a) (- x x1)) (- x2 x1))))
260
+ ((< x x3) (+ b (/ (* (- c b) (- x x2)) (- x3 x2))))
261
+ (else c)))
262
+ (if (< pct 1) (= pct 1))
263
+ (client
264
+ scaleX: (/ (* pct 128) 100)
265
+ scaleY: (/ (* pct 128) 100)
266
+ scaleSignal: (| (client scaleSignal?) SCALE_ON))))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sci2-ts",
3
- "version": "0.11.0",
3
+ "version": "0.13.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",
@@ -123,23 +123,74 @@
123
123
  }
124
124
  },
125
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"
126
+ "description": "How people are sized by where they stand: a level camera's horizon and full-size line, or sizes measured at up to three places across the floor (for a picture painted at an angle)",
127
+ "oneOf": [
128
+ {
129
+ "type": "object",
130
+ "additionalProperties": false,
131
+ "required": [
132
+ "horizon",
133
+ "fullSize"
134
+ ],
135
+ "properties": {
136
+ "horizon": {
137
+ "type": "integer",
138
+ "description": "The y the floor's lines meet at"
139
+ },
140
+ "fullSize": {
141
+ "type": "integer",
142
+ "description": "The y of a figure's feet where it's drawn at full size"
143
+ }
144
+ }
137
145
  },
138
- "fullSize": {
139
- "type": "integer",
140
- "description": "The y of a figure's feet where it's drawn at full size"
146
+ {
147
+ "type": "object",
148
+ "additionalProperties": false,
149
+ "required": [
150
+ "columns"
151
+ ],
152
+ "properties": {
153
+ "columns": {
154
+ "type": "array",
155
+ "minItems": 1,
156
+ "maxItems": 3,
157
+ "items": {
158
+ "type": "object",
159
+ "additionalProperties": false,
160
+ "required": [
161
+ "x",
162
+ "back",
163
+ "front"
164
+ ],
165
+ "properties": {
166
+ "x": {
167
+ "type": "integer",
168
+ "description": "Where across the floor it was measured"
169
+ },
170
+ "back": {
171
+ "type": "array",
172
+ "items": {
173
+ "type": "integer"
174
+ },
175
+ "minItems": 2,
176
+ "maxItems": 2,
177
+ "description": "[y, size in percent] at the back of the floor"
178
+ },
179
+ "front": {
180
+ "type": "array",
181
+ "items": {
182
+ "type": "integer"
183
+ },
184
+ "minItems": 2,
185
+ "maxItems": 2,
186
+ "description": "[y, size in percent] at the front of the floor"
187
+ }
188
+ }
189
+ }
190
+ }
191
+ }
141
192
  }
142
- }
193
+ ]
143
194
  },
144
195
  "walkable": {
145
196
  "$ref": "#/$defs/polygon",
@@ -43,10 +43,16 @@ export interface RoomSpec {
43
43
  * size. A figure's height is in proportion to how far below the horizon it stands, so it
44
44
  * shrinks towards the back and grows past fullSize at the front. The hero and props that
45
45
  * move are sized as they go; a still prop only with scale: true.
46
+ *
47
+ * A picture painted at an angle, where a line across the screen isn't one depth all the
48
+ * way along, gives sizes measured instead: `columns`, up to three places across the floor
49
+ * (left, middle, right), each with a size at the back and at the front of the floor.
46
50
  */
47
51
  perspective?: {
48
52
  horizon: number;
49
53
  fullSize: number;
54
+ } | {
55
+ columns: FloorColumn[];
50
56
  };
51
57
  /** The floor: a polygon the hero stays inside. */
52
58
  walkable?: Point[];
@@ -70,6 +76,12 @@ interface CharacterSpec {
70
76
  portrait?: number;
71
77
  }
72
78
  type Point = [number, number];
79
+ /** A place across the floor where sizes were measured: x, and [y, percent] at the back and front. */
80
+ export interface FloorColumn {
81
+ x: number;
82
+ back: [number, number];
83
+ front: [number, number];
84
+ }
73
85
  type Rect = [number, number, number, number];
74
86
  interface FeatureSpec {
75
87
  /** left, top, right, bottom (room coordinates). */
@@ -64,11 +64,37 @@ class SpecChecker {
64
64
  }
65
65
  if (s.perspective !== undefined) {
66
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)");
67
+ if (pv.columns !== undefined) {
68
+ this.known(pv, ["perspective"], ["columns"]);
69
+ if (!Array.isArray(pv.columns) || pv.columns.length < 1 || pv.columns.length > 3)
70
+ this.fail(["perspective", "columns"], "expected one to three places across the floor");
71
+ let lastX = -Infinity;
72
+ pv.columns.forEach((value, i) => {
73
+ const path = ["perspective", "columns", i];
74
+ const c = this.map(value, path);
75
+ this.known(c, path, ["x", "back", "front"]);
76
+ this.int(c.x, [...path, "x"], 0, 319);
77
+ if (c.x <= lastX)
78
+ this.fail([...path, "x"], "places go left to right");
79
+ lastX = c.x;
80
+ for (const end of ["back", "front"]) {
81
+ const v = c[end];
82
+ if (!Array.isArray(v) || v.length !== 2)
83
+ this.fail([...path, end], "expected [y, size in percent]");
84
+ this.int(v[0], [...path, end, 0], 0, 199);
85
+ this.int(v[1], [...path, end, 1], 1, 400);
86
+ }
87
+ if (c.back[0] >= c.front[0])
88
+ this.fail([...path, "front"], "the front is lower on the screen than the back (a bigger y)");
89
+ });
90
+ }
91
+ else {
92
+ this.known(pv, ["perspective"], ["horizon", "fullSize", "columns"]);
93
+ this.int(pv.horizon, ["perspective", "horizon"], -1000, 199);
94
+ this.int(pv.fullSize, ["perspective", "fullSize"], 0, 1000);
95
+ if (pv.fullSize <= pv.horizon)
96
+ this.fail(["perspective", "fullSize"], "must be below the horizon (a bigger y)");
97
+ }
72
98
  }
73
99
  if (s.walkable !== undefined)
74
100
  this.polygon(s.walkable, ["walkable"]);
@@ -188,6 +214,30 @@ class SpecChecker {
188
214
  }
189
215
  }
190
216
  const SCREEN_BOTTOM = 200;
217
+ /** A column's size at y: the straight line through its back and front. */
218
+ const along = (c, y) => c.back[1] + ((c.front[1] - c.back[1]) * (y - c.back[0])) / (c.front[0] - c.back[0]);
219
+ /** Three columns for the FloorScaler: one is used for all three, two get one between them. */
220
+ function floorColumns(cols) {
221
+ if (cols.length === 3)
222
+ return cols;
223
+ const [a, b] = [cols[0], cols.at(-1)];
224
+ if (cols.length === 1)
225
+ return [a, { ...a, x: a.x + 1 }, { ...a, x: a.x + 2 }];
226
+ const mid = (y) => Math.round((along(a, y) + along(b, y)) / 2);
227
+ return [a, { x: Math.round((a.x + b.x) / 2), back: [a.back[0], mid(a.back[0])], front: [a.front[0], mid(a.front[0])] }, b];
228
+ }
229
+ /** A figure's size at x, y among three columns: straight lines in y at each, then in x between them. */
230
+ function floorSize(cols, x, y) {
231
+ const [a, b, c] = cols.map((col) => along(col, y));
232
+ const [c1, c2, c3] = cols;
233
+ if (x <= c1.x)
234
+ return a;
235
+ if (x < c2.x)
236
+ return a + ((b - a) * (x - c1.x)) / (c2.x - c1.x);
237
+ if (x < c3.x)
238
+ return b + ((c - b) * (x - c2.x)) / (c3.x - c2.x);
239
+ return c;
240
+ }
191
241
  const num = (n) => (n < 0 ? `-$${(-n).toString(16)}` : String(n));
192
242
  function pushVal(v) {
193
243
  if (typeof v === "number")
@@ -761,7 +811,7 @@ class RoomCompiler {
761
811
  if (hero?.scale)
762
812
  egoCalls.push(["setScaler", [{ cls: "Scaler" }, hero.scale.front, hero.scale.back, hero.scale.frontY, hero.scale.backY]]);
763
813
  else if (perspective)
764
- egoCalls.push(["setScaler", [{ cls: "Scaler" }, ...perspective]]);
814
+ egoCalls.push(["setScaler", [{ cls: perspective.cls }, ...perspective.args]]);
765
815
  else
766
816
  egoCalls.push(["setScaler", [0]]);
767
817
  c.push(...send({ global: target.globals.ego }, egoCalls));
@@ -772,7 +822,7 @@ class RoomCompiler {
772
822
  for (const [name, p] of Object.entries(spec.props ?? {})) {
773
823
  c.push(...send({ obj: name }, [
774
824
  ["init", []],
775
- ...(perspective && p.moves && p.scale !== false ? [["setScaler", [{ cls: "Scaler" }, ...perspective]]] : []),
825
+ ...(perspective && p.moves && p.scale !== false ? [["setScaler", [{ cls: perspective.cls }, ...perspective.args]]] : []),
776
826
  ...(p.cycle === "forward" ? [["setCycle", [{ cls: this.target.forwardCycle }]]] : []),
777
827
  // A new instance of the game's Script class, running on the prop.
778
828
  ...(p.script ? [["setScript", [{ code: ["pushi #new", "push0", `class ${p.script}`, "send 4"] }]]] : []),
@@ -837,6 +887,8 @@ class RoomCompiler {
837
887
  "&rest 1", `super ${p.class} 12`,
838
888
  "pushi #setPri", "push1", "pTos priority", "ldi 1", "add", "push", `lofsa @${key}Mouth`, "send 6",
839
889
  "pushi #setPri", "push1", "pTos priority", "ldi 1", "add", "push", `lofsa @${key}Eyes`, "send 6",
890
+ // Its body in the room, if a prop has its name: where it stands picks the portrait's side.
891
+ ...(p.who && this.spec.props?.[key] ? send("self", [["who", [{ obj: key }]]]) : []),
840
892
  "ret",
841
893
  ], "");
842
894
  }
@@ -889,34 +941,65 @@ class RoomCompiler {
889
941
  if (cases.length)
890
942
  this.doVerb(name, "Feature", cases);
891
943
  }
944
+ /** The y range people stand in: the floor, and where the hero and props that move start. */
945
+ standingYs() {
946
+ return [
947
+ ...(this.spec.walkable ?? []).map((p) => p[1]),
948
+ ...Object.values(this.spec.props ?? {}).filter((p) => p.moves).map((p) => p.at[1]),
949
+ ...[this.spec.hero?.at, this.spec.hero?.enterTo].flatMap((p) => (p ? [p[1]] : [])),
950
+ ];
951
+ }
892
952
  /**
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.
953
+ * The room's perspective as a scaler class and its arguments.
954
+ *
955
+ * With a horizon, a Scaler (front, back, frontY, backY): sizes at the nearest and farthest
956
+ * places anyone stands. Size is a straight line in y, so the Scaler's in-between is exact;
957
+ * keeping to that range keeps its 16-bit arithmetic from overflowing.
958
+ *
959
+ * With measured columns, a FloorScaler with three of them (left, middle, right): one is
960
+ * used for all three, two get one halfway between them.
897
961
  */
898
962
  perspective() {
899
963
  const pv = this.spec.perspective;
900
964
  if (!pv)
901
965
  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);
966
+ const fail = (why) => { throw new ContentError(`room ${this.spec.room}: perspective: ${why}`); };
967
+ if ("columns" in pv) {
968
+ const cols = floorColumns(pv.columns);
969
+ const ys = this.standingYs();
970
+ const [lo, hi] = [Math.min(...ys, ...cols.map((c) => c.back[0])), Math.max(...ys, ...cols.map((c) => c.front[0]))];
971
+ // FloorScaler works out (front - back) * (y - backY), then (b - a) * (x - x1), in 16 bits.
972
+ for (const c of cols) {
973
+ for (const y of [lo, hi]) {
974
+ if (Math.abs((c.front[1] - c.back[1]) * (y - c.back[0])) > 32767)
975
+ fail(`sizes from ${c.back[1]}% to ${c.front[1]}% at x ${c.x} are too much for the scaler at y ${y}`);
976
+ const size = along(c, y);
977
+ if (size < 1 || size > 255)
978
+ fail(`at x ${c.x} a figure standing at y ${y} would be ${Math.round(size)}%`);
979
+ }
980
+ }
981
+ return { cls: "FloorScaler", args: cols.flatMap((c) => [c.x, c.back[0], c.back[1], c.front[0], c.front[1]]) };
982
+ }
983
+ const ys = this.standingYs().filter((y) => y > pv.horizon);
907
984
  const backY = ys.length ? Math.min(...ys) : pv.horizon + 1, frontY = ys.length ? Math.max(...ys) : SCREEN_BOTTOM - 1;
908
985
  const size = (y) => Math.round(((y - pv.horizon) / (pv.fullSize - pv.horizon)) * 100);
909
986
  const [front, back] = [size(frontY), size(backY)];
910
987
  // Scaler works out (front - back) * (y - backY) on the way.
911
988
  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];
989
+ fail(`sizes from ${back}% to ${front}% over ${frontY - backY} lines are too much for the Scaler`);
990
+ return { cls: "Scaler", args: [front, back, Math.max(frontY, backY + 1), backY] };
991
+ }
992
+ /** The size, in percent, of a figure standing at x, y. */
993
+ sizeAt(x, y) {
994
+ const pv = this.spec.perspective;
995
+ if ("columns" in pv)
996
+ return floorSize(floorColumns(pv.columns), x, y);
997
+ return ((y - pv.horizon) / (pv.fullSize - pv.horizon)) * 100;
914
998
  }
915
999
  prop(name, p) {
916
1000
  const cases = this.scriptedVerbs.get(name) ?? [];
917
1001
  // 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;
1002
+ const fixed = this.spec.perspective && p.scale === true && !p.moves ? Math.round((this.sizeAt(p.at[0], p.at[1]) / 100) * 128) : undefined;
920
1003
  this.instance(name, p.moves ? "Actor" : "Prop", {
921
1004
  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
1005
  ...(fixed === undefined ? {} : { scaleSignal: 1, scaleX: fixed, scaleY: fixed }),
@@ -41,8 +41,12 @@ export interface RoomSpec {
41
41
  * size. A figure's height is in proportion to how far below the horizon it stands, so it
42
42
  * shrinks towards the back and grows past fullSize at the front. The hero and props that
43
43
  * move are sized as they go; a still prop only with scale: true.
44
+ *
45
+ * A picture painted at an angle, where a line across the screen isn't one depth all the
46
+ * way along, gives sizes measured instead: `columns`, up to three places across the floor
47
+ * (left, middle, right), each with a size at the back and at the front of the floor.
44
48
  */
45
- perspective?: { horizon: number; fullSize: number };
49
+ perspective?: { horizon: number; fullSize: number } | { columns: FloorColumn[] };
46
50
  /** The floor: a polygon the hero stays inside. */
47
51
  walkable?: Point[];
48
52
  /** Polygons the hero walks around. */
@@ -67,6 +71,13 @@ interface CharacterSpec {
67
71
  }
68
72
 
69
73
  type Point = [number, number];
74
+
75
+ /** A place across the floor where sizes were measured: x, and [y, percent] at the back and front. */
76
+ export interface FloorColumn {
77
+ x: number;
78
+ back: [number, number];
79
+ front: [number, number];
80
+ }
70
81
  type Rect = [number, number, number, number];
71
82
 
72
83
  interface FeatureSpec {
@@ -212,10 +223,31 @@ class SpecChecker {
212
223
  }
213
224
  if (s.perspective !== undefined) {
214
225
  const pv = this.map(s.perspective, ["perspective"]);
215
- this.known(pv, ["perspective"], ["horizon", "fullSize"]);
216
- this.int(pv.horizon, ["perspective", "horizon"], -1000, 199);
217
- this.int(pv.fullSize, ["perspective", "fullSize"], 0, 1000);
218
- if ((pv.fullSize as number) <= (pv.horizon as number)) this.fail(["perspective", "fullSize"], "must be below the horizon (a bigger y)");
226
+ if (pv.columns !== undefined) {
227
+ this.known(pv, ["perspective"], ["columns"]);
228
+ if (!Array.isArray(pv.columns) || pv.columns.length < 1 || pv.columns.length > 3) this.fail(["perspective", "columns"], "expected one to three places across the floor");
229
+ let lastX = -Infinity;
230
+ (pv.columns as unknown[]).forEach((value, i) => {
231
+ const path = ["perspective", "columns", i];
232
+ const c = this.map(value, path);
233
+ this.known(c, path, ["x", "back", "front"]);
234
+ this.int(c.x, [...path, "x"], 0, 319);
235
+ if ((c.x as number) <= lastX) this.fail([...path, "x"], "places go left to right");
236
+ lastX = c.x as number;
237
+ for (const end of ["back", "front"] as const) {
238
+ const v = c[end];
239
+ if (!Array.isArray(v) || v.length !== 2) this.fail([...path, end], "expected [y, size in percent]");
240
+ this.int((v as unknown[])[0], [...path, end, 0], 0, 199);
241
+ this.int((v as unknown[])[1], [...path, end, 1], 1, 400);
242
+ }
243
+ if ((c.back as number[])[0]! >= (c.front as number[])[0]!) this.fail([...path, "front"], "the front is lower on the screen than the back (a bigger y)");
244
+ });
245
+ } else {
246
+ this.known(pv, ["perspective"], ["horizon", "fullSize", "columns"]);
247
+ this.int(pv.horizon, ["perspective", "horizon"], -1000, 199);
248
+ this.int(pv.fullSize, ["perspective", "fullSize"], 0, 1000);
249
+ if ((pv.fullSize as number) <= (pv.horizon as number)) this.fail(["perspective", "fullSize"], "must be below the horizon (a bigger y)");
250
+ }
219
251
  }
220
252
  if (s.walkable !== undefined) this.polygon(s.walkable, ["walkable"]);
221
253
  if (s.obstacles !== undefined) {
@@ -313,6 +345,28 @@ type Val = number | { obj: string } | { cls: string } | "self" | { code: string[
313
345
  type To = { global: number } | { obj: string } | { cls: string } | "self" | { superOf: string };
314
346
 
315
347
  const SCREEN_BOTTOM = 200;
348
+
349
+ /** A column's size at y: the straight line through its back and front. */
350
+ const along = (c: FloorColumn, y: number) => c.back[1] + ((c.front[1] - c.back[1]) * (y - c.back[0])) / (c.front[0] - c.back[0]);
351
+
352
+ /** Three columns for the FloorScaler: one is used for all three, two get one between them. */
353
+ function floorColumns(cols: FloorColumn[]): [FloorColumn, FloorColumn, FloorColumn] {
354
+ if (cols.length === 3) return cols as [FloorColumn, FloorColumn, FloorColumn];
355
+ const [a, b] = [cols[0]!, cols.at(-1)!];
356
+ if (cols.length === 1) return [a, { ...a, x: a.x + 1 }, { ...a, x: a.x + 2 }];
357
+ const mid = (y: number) => Math.round((along(a, y) + along(b, y)) / 2);
358
+ return [a, { x: Math.round((a.x + b.x) / 2), back: [a.back[0], mid(a.back[0])], front: [a.front[0], mid(a.front[0])] }, b];
359
+ }
360
+
361
+ /** A figure's size at x, y among three columns: straight lines in y at each, then in x between them. */
362
+ function floorSize(cols: [FloorColumn, FloorColumn, FloorColumn], x: number, y: number): number {
363
+ const [a, b, c] = cols.map((col) => along(col, y)) as [number, number, number];
364
+ const [c1, c2, c3] = cols;
365
+ if (x <= c1.x) return a;
366
+ if (x < c2.x) return a + ((b - a) * (x - c1.x)) / (c2.x - c1.x);
367
+ if (x < c3.x) return b + ((c - b) * (x - c2.x)) / (c3.x - c2.x);
368
+ return c;
369
+ }
316
370
  const num = (n: number) => (n < 0 ? `-$${(-n).toString(16)}` : String(n));
317
371
 
318
372
  function pushVal(v: Val): string[] {
@@ -899,7 +953,7 @@ class RoomCompiler {
899
953
  // Each room sizes the hero its own way, or not at all.
900
954
  const perspective = this.perspective();
901
955
  if (hero?.scale) egoCalls.push(["setScaler", [{ cls: "Scaler" }, hero.scale.front, hero.scale.back, hero.scale.frontY, hero.scale.backY]]);
902
- else if (perspective) egoCalls.push(["setScaler", [{ cls: "Scaler" }, ...perspective]]);
956
+ else if (perspective) egoCalls.push(["setScaler", [{ cls: perspective.cls }, ...perspective.args]]);
903
957
  else egoCalls.push(["setScaler", [0]]);
904
958
  c.push(...send({ global: target.globals.ego }, egoCalls));
905
959
  if (spec.music !== undefined) c.push(...this.playMusic(spec.music));
@@ -907,7 +961,7 @@ class RoomCompiler {
907
961
  for (const [name, p] of Object.entries(spec.props ?? {})) {
908
962
  c.push(...send({ obj: name }, [
909
963
  ["init", []],
910
- ...(perspective && p.moves && p.scale !== false ? [["setScaler", [{ cls: "Scaler" }, ...perspective]] as [string, Val[]]] : []),
964
+ ...(perspective && p.moves && p.scale !== false ? [["setScaler", [{ cls: perspective.cls }, ...perspective.args]] as [string, Val[]]] : []),
911
965
  ...(p.cycle === "forward" ? [["setCycle", [{ cls: this.target.forwardCycle }]] as [string, Val[]]] : []),
912
966
  // A new instance of the game's Script class, running on the prop.
913
967
  ...(p.script ? [["setScript", [{ code: ["pushi #new", "push0", `class ${p.script}`, "send 4"] }]] as [string, Val[]]] : []),
@@ -975,6 +1029,8 @@ class RoomCompiler {
975
1029
  "&rest 1", `super ${p.class} 12`,
976
1030
  "pushi #setPri", "push1", "pTos priority", "ldi 1", "add", "push", `lofsa @${key}Mouth`, "send 6",
977
1031
  "pushi #setPri", "push1", "pTos priority", "ldi 1", "add", "push", `lofsa @${key}Eyes`, "send 6",
1032
+ // Its body in the room, if a prop has its name: where it stands picks the portrait's side.
1033
+ ...(p.who && this.spec.props?.[key] ? send("self", [["who", [{ obj: key }]]]) : []),
978
1034
  "ret",
979
1035
  ],
980
1036
  "",
@@ -1039,33 +1095,63 @@ class RoomCompiler {
1039
1095
  if (cases.length) this.doVerb(name, "Feature", cases);
1040
1096
  }
1041
1097
 
1098
+ /** The y range people stand in: the floor, and where the hero and props that move start. */
1099
+ private standingYs(): number[] {
1100
+ return [
1101
+ ...(this.spec.walkable ?? []).map((p) => p[1]),
1102
+ ...Object.values(this.spec.props ?? {}).filter((p) => p.moves).map((p) => p.at[1]),
1103
+ ...[this.spec.hero?.at, this.spec.hero?.enterTo].flatMap((p) => (p ? [p[1]] : [])),
1104
+ ];
1105
+ }
1106
+
1042
1107
  /**
1043
- * The room's perspective as Scaler arguments (front, back, frontY, backY): sizes at the
1044
- * nearest and farthest places anyone stands (the floor, and where the hero and props that
1045
- * move start). Size is a straight line in y, so the Scaler's in-between is exact; keeping
1046
- * to that range keeps its 16-bit arithmetic from overflowing.
1108
+ * The room's perspective as a scaler class and its arguments.
1109
+ *
1110
+ * With a horizon, a Scaler (front, back, frontY, backY): sizes at the nearest and farthest
1111
+ * places anyone stands. Size is a straight line in y, so the Scaler's in-between is exact;
1112
+ * keeping to that range keeps its 16-bit arithmetic from overflowing.
1113
+ *
1114
+ * With measured columns, a FloorScaler with three of them (left, middle, right): one is
1115
+ * used for all three, two get one halfway between them.
1047
1116
  */
1048
- private perspective(): [number, number, number, number] | undefined {
1117
+ private perspective(): { cls: string; args: number[] } | undefined {
1049
1118
  const pv = this.spec.perspective;
1050
1119
  if (!pv) return undefined;
1051
- const ys = [
1052
- ...(this.spec.walkable ?? []).map((p) => p[1]),
1053
- ...Object.values(this.spec.props ?? {}).filter((p) => p.moves).map((p) => p.at[1]),
1054
- ...[this.spec.hero?.at, this.spec.hero?.enterTo].flatMap((p) => (p ? [p[1]] : [])),
1055
- ].filter((y) => y > pv.horizon);
1120
+ const fail = (why: string): never => { throw new ContentError(`room ${this.spec.room}: perspective: ${why}`); };
1121
+ if ("columns" in pv) {
1122
+ const cols = floorColumns(pv.columns);
1123
+ const ys = this.standingYs();
1124
+ const [lo, hi] = [Math.min(...ys, ...cols.map((c) => c.back[0])), Math.max(...ys, ...cols.map((c) => c.front[0]))];
1125
+ // FloorScaler works out (front - back) * (y - backY), then (b - a) * (x - x1), in 16 bits.
1126
+ for (const c of cols) {
1127
+ for (const y of [lo, hi]) {
1128
+ if (Math.abs((c.front[1] - c.back[1]) * (y - c.back[0])) > 32767) fail(`sizes from ${c.back[1]}% to ${c.front[1]}% at x ${c.x} are too much for the scaler at y ${y}`);
1129
+ const size = along(c, y);
1130
+ if (size < 1 || size > 255) fail(`at x ${c.x} a figure standing at y ${y} would be ${Math.round(size)}%`);
1131
+ }
1132
+ }
1133
+ return { cls: "FloorScaler", args: cols.flatMap((c) => [c.x, c.back[0], c.back[1], c.front[0], c.front[1]]) };
1134
+ }
1135
+ const ys = this.standingYs().filter((y) => y > pv.horizon);
1056
1136
  const backY = ys.length ? Math.min(...ys) : pv.horizon + 1, frontY = ys.length ? Math.max(...ys) : SCREEN_BOTTOM - 1;
1057
1137
  const size = (y: number) => Math.round(((y - pv.horizon) / (pv.fullSize - pv.horizon)) * 100);
1058
1138
  const [front, back] = [size(frontY), size(backY)];
1059
1139
  // Scaler works out (front - back) * (y - backY) on the way.
1060
- if ((front - back) * (frontY - backY) > 32767) throw new ContentError(`room ${this.spec.room}: perspective: sizes from ${back}% to ${front}% over ${frontY - backY} lines are too much for the Scaler`);
1061
- return [front, back, Math.max(frontY, backY + 1), backY];
1140
+ if ((front - back) * (frontY - backY) > 32767) fail(`sizes from ${back}% to ${front}% over ${frontY - backY} lines are too much for the Scaler`);
1141
+ return { cls: "Scaler", args: [front, back, Math.max(frontY, backY + 1), backY] };
1142
+ }
1143
+
1144
+ /** The size, in percent, of a figure standing at x, y. */
1145
+ private sizeAt(x: number, y: number): number {
1146
+ const pv = this.spec.perspective!;
1147
+ if ("columns" in pv) return floorSize(floorColumns(pv.columns), x, y);
1148
+ return ((y - pv.horizon) / (pv.fullSize - pv.horizon)) * 100;
1062
1149
  }
1063
1150
 
1064
1151
  private prop(name: string, p: PropSpec): void {
1065
1152
  const cases = this.scriptedVerbs.get(name) ?? [];
1066
1153
  // A still prop is sized once, for where it is.
1067
- const pv = this.spec.perspective;
1068
- const fixed = pv && p.scale === true && !p.moves ? Math.round(((p.at[1] - pv.horizon) / (pv.fullSize - pv.horizon)) * 128) : undefined;
1154
+ const fixed = this.spec.perspective && p.scale === true && !p.moves ? Math.round((this.sizeAt(p.at[0], p.at[1]) / 100) * 128) : undefined;
1069
1155
  this.instance(
1070
1156
  name, p.moves ? "Actor" : "Prop",
1071
1157
  {
@@ -40,7 +40,8 @@ export interface Target {
40
40
  /**
41
41
  * A character with a portrait view: loop 0 the bust, loop 1 mouth shapes, loop 2 eyes
42
42
  * (cel 0 open), all cels the same size so the parts line up. The talker class gets
43
- * `init: mouth bust eyes frame`; parts are a View (bust) and Props at `at`.
43
+ * `init: mouth bust eyes frame`; parts are a View (bust) and Props at `at`. With `who`,
44
+ * the talker is also told the character's prop in the room, if it has one (`who:`).
44
45
  */
45
46
  portrait: {
46
47
  class: string;
@@ -48,6 +49,7 @@ export interface Target {
48
49
  at: [number, number];
49
50
  frameSignal: number;
50
51
  partSignal: number;
52
+ who?: boolean;
51
53
  };
52
54
  /**
53
55
  * Conversations: an instance of this class runs a menu of topics for a thing (e.g. a
@@ -37,9 +37,10 @@ export interface Target {
37
37
  /**
38
38
  * A character with a portrait view: loop 0 the bust, loop 1 mouth shapes, loop 2 eyes
39
39
  * (cel 0 open), all cels the same size so the parts line up. The talker class gets
40
- * `init: mouth bust eyes frame`; parts are a View (bust) and Props at `at`.
40
+ * `init: mouth bust eyes frame`; parts are a View (bust) and Props at `at`. With `who`,
41
+ * the talker is also told the character's prop in the room, if it has one (`who:`).
41
42
  */
42
- portrait: { class: string; props: Record<string, number>; at: [number, number]; frameSignal: number; partSignal: number };
43
+ portrait: { class: string; props: Record<string, number>; at: [number, number]; frameSignal: number; partSignal: number; who?: boolean };
43
44
  /**
44
45
  * Conversations: an instance of this class runs a menu of topics for a thing (e.g. a
45
46
  * Teller with `init: client modNum sayNoun verb rootNoun`; topic labels are messages at
@@ -31,8 +31,8 @@ export function libraryTarget(globals, items = {}) {
31
31
  heroTalker: 98,
32
32
  firstRoomTalker: 200,
33
33
  voice: { class: "Talker", props: {} },
34
- // Portraits stand at the top left, the text beside them.
35
- portrait: { class: "PortraitTalker", props: {}, at: [8, 8], frameSignal: 0, partSignal: 0 },
34
+ // Portraits stand at the top, left or right by where the character is (who:).
35
+ portrait: { class: "PortraitTalker", props: {}, at: [8, 8], frameSignal: 0, partSignal: 0, who: true },
36
36
  teller: { class: "Teller", props: {}, verb: 2 },
37
37
  forwardCycle: "Forward",
38
38
  closeUp: "CloseUp",
@@ -33,8 +33,8 @@ export function libraryTarget(globals: readonly string[], items: Record<string,
33
33
  heroTalker: 98,
34
34
  firstRoomTalker: 200,
35
35
  voice: { class: "Talker", props: {} },
36
- // Portraits stand at the top left, the text beside them.
37
- portrait: { class: "PortraitTalker", props: {}, at: [8, 8], frameSignal: 0, partSignal: 0 },
36
+ // Portraits stand at the top, left or right by where the character is (who:).
37
+ portrait: { class: "PortraitTalker", props: {}, at: [8, 8], frameSignal: 0, partSignal: 0, who: true },
38
38
  teller: { class: "Teller", props: {}, verb: 2 },
39
39
  forwardCycle: "Forward",
40
40
  closeUp: "CloseUp",