sci2-ts 0.12.0 → 0.14.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.14.0](https://github.com/rlueder/sci-ts/compare/v0.13.0...v0.14.0) (2026-10-02)
8
+
9
+ ### Added
10
+
11
+ * **lib:** figures drawn smaller walk and step slower, and change size in steps ([#16](https://github.com/rlueder/sci-ts/issues/16)) ([06f2b1d](https://github.com/rlueder/sci-ts/commit/06f2b1d91fd95d5b648104b435eb58e3355ba808))
12
+
13
+ ## [0.13.0](https://github.com/rlueder/sci-ts/compare/v0.12.0...v0.13.0) (2026-10-02)
14
+
15
+ ### Added
16
+
17
+ * **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))
18
+
7
19
  ## [0.12.0](https://github.com/rlueder/sci-ts/compare/v0.11.0...v0.12.0) (2026-10-02)
8
20
 
9
21
  ### Added
package/lib/997.sc CHANGED
@@ -6,6 +6,8 @@
6
6
  ;; Walks its client in a straight line to (x, y), a step (xStep, yStep) every
7
7
  ;; moveSpeed cycles. The interpreter does the stepping (InitBresen, DoBresen) and sends
8
8
  ;; moveDone on arrival; the client then calls motionCue:, which cues the caller.
9
+ ;; A client drawn smaller than full size (farther off) steps less often, in proportion,
10
+ ;; so it doesn't slide across the floor.
9
11
  (class Motion of Obj
10
12
  (properties
11
13
  client 0
@@ -13,6 +15,7 @@
13
15
  x 0 y 0
14
16
  dx 0 dy 0
15
17
  b-moveCnt 0 b-i1 0 b-i2 0 b-di 0 b-xAxis 0 b-incr 0
18
+ progress 0 ; towards the next step, in 128ths of a cycle at full size
16
19
  completed 0)
17
20
 
18
21
  (method (init who toX toY whoCares)
@@ -25,12 +28,21 @@
25
28
  (= x toX)
26
29
  (= y toY)
27
30
  (= b-moveCnt gameTime)
31
+ (= progress 0)
28
32
  (client setHeading: (GetAngle (client x?) (client y?) x y))
29
33
  (InitBresen self))
30
34
 
31
- (method (doit)
32
- (if (>= (- gameTime b-moveCnt) (client moveSpeed?))
33
- (= b-moveCnt gameTime)
35
+ ;; Each cycle counts for the client's size (128 at full size); a step takes moveSpeed
36
+ ;; cycles' worth.
37
+ (method (doit &tmp t e)
38
+ (= t (* (client moveSpeed?) 128))
39
+ ;; (After a long pause, at most 16 cycles count, so the sum stays in 16 bits.)
40
+ (if (> (= e (- gameTime b-moveCnt)) 16) (= e 16))
41
+ (+= progress (* e (if (& (client scaleSignal?) SCALE_ON) (client scaleX?) else 128)))
42
+ (= b-moveCnt gameTime)
43
+ (if (> progress t) (= progress t))
44
+ (if (>= progress t)
45
+ (-= progress t)
34
46
  (DoBresen self)))
35
47
 
36
48
  (method (moveDone)
@@ -139,8 +151,22 @@
139
151
  (if (> c (self lastCel:)) (= c 0))
140
152
  (client cel: c)))
141
153
 
142
- ;; Round and round while the client moves; cel 0 when it stands.
154
+ ;; Round and round while the client moves; cel 0 when it stands. Its legs keep pace with
155
+ ;; its steps: drawn smaller, it turns its cels less often, in proportion.
143
156
  (class Walk of Forward
157
+ (properties
158
+ progress 0)
159
+
160
+ (method (nextCel &tmp t e)
161
+ (= t (* (client cycleSpeed?) 128))
162
+ (if (> (= e (- gameTime cycleCnt)) 16) (= e 16))
163
+ (+= progress (* e (if (& (client scaleSignal?) SCALE_ON) (client scaleX?) else 128)))
164
+ (= cycleCnt gameTime)
165
+ (if (> progress t) (= progress t))
166
+ (if (< progress t) (return (client cel?)))
167
+ (-= progress t)
168
+ (return (+ (client cel?) cycleDir)))
169
+
144
170
  (method (doit)
145
171
  (if (client mover?)
146
172
  (super doit:)
@@ -198,12 +224,15 @@
198
224
  (client cel: c)))))
199
225
 
200
226
  ;; Sizes its client by how far up the screen it stands: frontSize percent at frontY,
201
- ;; backSize at backY, in between in proportion.
227
+ ;; backSize at backY, in between in proportion. The size changes in steps of at least `step`
228
+ ;; percent: a figure redrawn at a new size for every line it moves shimmers.
202
229
  (class Scaler of Obj
203
230
  (properties
204
231
  client 0
205
232
  frontSize 100 backSize 100
206
- frontY 190 backY 0)
233
+ frontY 190 backY 0
234
+ step 2
235
+ shown -1) ; the size the client is drawn at
207
236
 
208
237
  (method (init who fs bs fy by)
209
238
  (= client who)
@@ -220,7 +249,52 @@
220
249
  ((>= y frontY) frontSize)
221
250
  ((<= y backY) backSize)
222
251
  (else (+ backSize (/ (* (- frontSize backSize) (- y backY)) (- frontY backY))))))
223
- (client
224
- scaleX: (/ (* pct 128) 100)
225
- scaleY: (/ (* pct 128) 100)
226
- scaleSignal: (| (client scaleSignal?) SCALE_ON))))
252
+ (self show: pct))
253
+
254
+ ;; Draws the client at pct percent, if that's a step away from its size now.
255
+ (method (show pct &tmp d)
256
+ (= d (- pct shown))
257
+ (if (< d 0) (= d (- d)))
258
+ (if (or (== shown -1) (>= d step))
259
+ (= shown pct)
260
+ (client
261
+ scaleX: (/ (* pct 128) 100)
262
+ scaleY: (/ (* pct 128) 100)
263
+ scaleSignal: (| (client scaleSignal?) SCALE_ON)))))
264
+
265
+ ;; Sizes its client on a floor seen at an angle, where one line across the screen isn't the
266
+ ;; same depth all the way along. Sizes are measured at the back and front of the floor at
267
+ ;; three places across it (left, middle, right): at each of those, size changes in a straight
268
+ ;; line with y, as perspective does; between them, in a straight line with x.
269
+ ;; (actor setScaler: FloorScaler x1 backY1 back1 frontY1 front1 x2 ... x3 ...)
270
+ (class FloorScaler of Scaler
271
+ (properties
272
+ x1 0 yb1 0 sb1 100 yf1 1 sf1 100
273
+ x2 1 yb2 0 sb2 100 yf2 1 sf2 100
274
+ x3 2 yb3 0 sb3 100 yf3 1 sf3 100)
275
+
276
+ (method (init who a1 b1 c1 d1 e1 a2 b2 c2 d2 e2 a3 b3 c3 d3 e3)
277
+ (= client who)
278
+ (= x1 a1) (= yb1 b1) (= sb1 c1) (= yf1 d1) (= sf1 e1)
279
+ (= x2 a2) (= yb2 b2) (= sb2 c2) (= yf2 d2) (= sf2 e2)
280
+ (= x3 a3) (= yb3 b3) (= sb3 c3) (= yf3 d3) (= sf3 e3)
281
+ (self doit:))
282
+
283
+ ;; The size at y where one place was measured: the straight line through its back and front.
284
+ (method (along y yb sb yf sf)
285
+ (return (+ sb (/ (* (- sf sb) (- y yb)) (- yf yb)))))
286
+
287
+ (method (doit &tmp x y a b c pct)
288
+ (= x (client x?))
289
+ (= y (client y?))
290
+ (= a (self along: y yb1 sb1 yf1 sf1))
291
+ (= b (self along: y yb2 sb2 yf2 sf2))
292
+ (= c (self along: y yb3 sb3 yf3 sf3))
293
+ (= pct
294
+ (cond
295
+ ((<= x x1) a)
296
+ ((< x x2) (+ a (/ (* (- b a) (- x x1)) (- x2 x1))))
297
+ ((< x x3) (+ b (/ (* (- c b) (- x x2)) (- x3 x2))))
298
+ (else c)))
299
+ (if (< pct 1) (= pct 1))
300
+ (self show: pct)))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sci2-ts",
3
- "version": "0.12.0",
3
+ "version": "0.14.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"] }]]] : []),
@@ -891,34 +941,65 @@ class RoomCompiler {
891
941
  if (cases.length)
892
942
  this.doVerb(name, "Feature", cases);
893
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
+ }
894
952
  /**
895
- * The room's perspective as Scaler arguments (front, back, frontY, backY): sizes at the
896
- * nearest and farthest places anyone stands (the floor, and where the hero and props that
897
- * move start). Size is a straight line in y, so the Scaler's in-between is exact; keeping
898
- * 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.
899
961
  */
900
962
  perspective() {
901
963
  const pv = this.spec.perspective;
902
964
  if (!pv)
903
965
  return undefined;
904
- const ys = [
905
- ...(this.spec.walkable ?? []).map((p) => p[1]),
906
- ...Object.values(this.spec.props ?? {}).filter((p) => p.moves).map((p) => p.at[1]),
907
- ...[this.spec.hero?.at, this.spec.hero?.enterTo].flatMap((p) => (p ? [p[1]] : [])),
908
- ].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);
909
984
  const backY = ys.length ? Math.min(...ys) : pv.horizon + 1, frontY = ys.length ? Math.max(...ys) : SCREEN_BOTTOM - 1;
910
985
  const size = (y) => Math.round(((y - pv.horizon) / (pv.fullSize - pv.horizon)) * 100);
911
986
  const [front, back] = [size(frontY), size(backY)];
912
987
  // Scaler works out (front - back) * (y - backY) on the way.
913
988
  if ((front - back) * (frontY - backY) > 32767)
914
- throw new ContentError(`room ${this.spec.room}: perspective: sizes from ${back}% to ${front}% over ${frontY - backY} lines are too much for the Scaler`);
915
- 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;
916
998
  }
917
999
  prop(name, p) {
918
1000
  const cases = this.scriptedVerbs.get(name) ?? [];
919
1001
  // A still prop is sized once, for where it is.
920
- const pv = this.spec.perspective;
921
- 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;
922
1003
  this.instance(name, p.moves ? "Actor" : "Prop", {
923
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,
924
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[]]] : []),
@@ -1041,33 +1095,63 @@ class RoomCompiler {
1041
1095
  if (cases.length) this.doVerb(name, "Feature", cases);
1042
1096
  }
1043
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
+
1044
1107
  /**
1045
- * The room's perspective as Scaler arguments (front, back, frontY, backY): sizes at the
1046
- * nearest and farthest places anyone stands (the floor, and where the hero and props that
1047
- * move start). Size is a straight line in y, so the Scaler's in-between is exact; keeping
1048
- * 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.
1049
1116
  */
1050
- private perspective(): [number, number, number, number] | undefined {
1117
+ private perspective(): { cls: string; args: number[] } | undefined {
1051
1118
  const pv = this.spec.perspective;
1052
1119
  if (!pv) return undefined;
1053
- const ys = [
1054
- ...(this.spec.walkable ?? []).map((p) => p[1]),
1055
- ...Object.values(this.spec.props ?? {}).filter((p) => p.moves).map((p) => p.at[1]),
1056
- ...[this.spec.hero?.at, this.spec.hero?.enterTo].flatMap((p) => (p ? [p[1]] : [])),
1057
- ].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);
1058
1136
  const backY = ys.length ? Math.min(...ys) : pv.horizon + 1, frontY = ys.length ? Math.max(...ys) : SCREEN_BOTTOM - 1;
1059
1137
  const size = (y: number) => Math.round(((y - pv.horizon) / (pv.fullSize - pv.horizon)) * 100);
1060
1138
  const [front, back] = [size(frontY), size(backY)];
1061
1139
  // Scaler works out (front - back) * (y - backY) on the way.
1062
- 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`);
1063
- 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;
1064
1149
  }
1065
1150
 
1066
1151
  private prop(name: string, p: PropSpec): void {
1067
1152
  const cases = this.scriptedVerbs.get(name) ?? [];
1068
1153
  // A still prop is sized once, for where it is.
1069
- const pv = this.spec.perspective;
1070
- 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;
1071
1155
  this.instance(
1072
1156
  name, p.moves ? "Actor" : "Prop",
1073
1157
  {