sci2-ts 0.9.0 → 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 +6 -0
- package/apps/viewer/src/files.js +3 -1
- package/apps/viewer/src/files.ts +3 -1
- package/lib/992.sc +17 -3
- package/lib/996.sc +41 -20
- package/lib/999.sc +2 -1
- package/lib/system.sh +9 -0
- package/package.json +1 -1
- package/packages/content/room.schema.json +24 -1
- package/packages/content/src/room.d.ts +45 -2
- package/packages/content/src/room.js +69 -7
- package/packages/content/src/room.ts +102 -9
- package/packages/content/src/yarn.d.ts +13 -0
- package/packages/content/src/yarn.js +52 -2
- package/packages/content/src/yarn.ts +51 -2
- package/packages/sci/src/audio/sol.d.ts +2 -0
- package/packages/sci/src/audio/sol.js +2 -1
- package/packages/sci/src/audio/sol.ts +2 -1
- package/packages/sci/src/audio/write.d.ts +37 -2
- package/packages/sci/src/audio/write.js +96 -7
- package/packages/sci/src/audio/write.ts +96 -7
- package/packages/sci/src/vm/kernels/audio.js +1 -1
- package/packages/sci/src/vm/kernels/audio.ts +1 -1
- package/tools/cli.js +36 -1
- package/tools/cli.ts +30 -1
- package/tools/game/audio.d.ts +14 -0
- package/tools/game/audio.js +62 -0
- package/tools/game/audio.ts +58 -0
- package/tools/game/build.d.ts +7 -1
- package/tools/game/build.js +59 -2
- package/tools/game/build.ts +62 -4
- package/tools/game/lines.d.ts +8 -0
- package/tools/game/lines.js +53 -0
- package/tools/game/lines.ts +44 -0
- package/tools/game/voices.d.ts +37 -0
- package/tools/game/voices.js +44 -0
- package/tools/game/voices.ts +67 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,12 @@ 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
|
+
|
|
7
13
|
## [0.9.0](https://github.com/rlueder/sci-ts/compare/v0.8.2...v0.9.0) (2026-10-02)
|
|
8
14
|
|
|
9
15
|
### Added
|
package/apps/viewer/src/files.js
CHANGED
|
@@ -13,7 +13,9 @@ export const httpFiles = {
|
|
|
13
13
|
},
|
|
14
14
|
async readRange(path, offset, length) {
|
|
15
15
|
const res = await fetch(`${BASE}game/${path}`, { headers: { Range: `bytes=${offset}-${offset + length - 1}` } });
|
|
16
|
-
|
|
16
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
17
|
+
// A server that ignores Range (Python's http.server) sends the whole file.
|
|
18
|
+
return res.status === 206 ? bytes : bytes.subarray(offset, offset + length);
|
|
17
19
|
},
|
|
18
20
|
async list(dir) {
|
|
19
21
|
if (import.meta.env.DEV)
|
package/apps/viewer/src/files.ts
CHANGED
|
@@ -15,7 +15,9 @@ export const httpFiles: FileSource = {
|
|
|
15
15
|
},
|
|
16
16
|
async readRange(path, offset, length) {
|
|
17
17
|
const res = await fetch(`${BASE}game/${path}`, { headers: { Range: `bytes=${offset}-${offset + length - 1}` } });
|
|
18
|
-
|
|
18
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
19
|
+
// A server that ignores Range (Python's http.server) sends the whole file.
|
|
20
|
+
return res.status === 206 ? bytes : bytes.subarray(offset, offset + length);
|
|
19
21
|
},
|
|
20
22
|
async list(dir) {
|
|
21
23
|
if (import.meta.env.DEV) return (await fetch(`/__list?dir=${encodeURIComponent(dir)}`)).json();
|
package/lib/992.sc
CHANGED
|
@@ -58,9 +58,17 @@
|
|
|
58
58
|
add: SR_SAVE "Save the game"
|
|
59
59
|
add: SR_RESTORE "Restore a game"
|
|
60
60
|
add: SR_RESTART "Start again"
|
|
61
|
-
add: SR_TEXT (String STRING_FORMAT "Text speed: %s" (self textSpeedName:))
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
add: SR_TEXT (String STRING_FORMAT "Text speed: %s" (self textSpeedName:)))
|
|
62
|
+
;; Only a game with recorded lines asks how to have them.
|
|
63
|
+
(if (DoAudio 9)
|
|
64
|
+
(menu add: SR_SPEECH (String STRING_FORMAT "Speech: %s" (self speechName:))))
|
|
65
|
+
(menu add: 0 "Carry on" show: self))
|
|
66
|
+
|
|
67
|
+
(method (speechName)
|
|
68
|
+
(switch speech
|
|
69
|
+
(SPEECH_TEXT (return "text only"))
|
|
70
|
+
(SPEECH_VOICE (return "voice only"))
|
|
71
|
+
(else (return "voice and text"))))
|
|
64
72
|
|
|
65
73
|
(method (textSpeedName)
|
|
66
74
|
(switch textSpeed
|
|
@@ -133,6 +141,12 @@
|
|
|
133
141
|
(TEXT_NORMAL (= textSpeed TEXT_FAST))
|
|
134
142
|
(TEXT_FAST (= textSpeed TEXT_CLICK))
|
|
135
143
|
(else (= textSpeed TEXT_SLOW)))
|
|
144
|
+
(self showMenu:))
|
|
145
|
+
(SR_SPEECH
|
|
146
|
+
(switch speech
|
|
147
|
+
(SPEECH_BOTH (= speech SPEECH_VOICE))
|
|
148
|
+
(SPEECH_VOICE (= speech SPEECH_TEXT))
|
|
149
|
+
(else (= speech SPEECH_BOTH)))
|
|
136
150
|
(self showMenu:))))
|
|
137
151
|
(SR_SAVE (if v (self askDescription: (- v 1))))
|
|
138
152
|
(SR_RESTORE
|
package/lib/996.sc
CHANGED
|
@@ -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
|
-
(
|
|
115
|
-
((
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
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
|
|
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
|
-
;;
|
|
204
|
-
|
|
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
|
-
(
|
|
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
|
|
35
|
+
textSpeed ; how long a line stays up: TEXT_NORMAL, TEXT_SLOW, TEXT_FAST, TEXT_CLICK
|
|
36
|
+
speech) ; whether recorded lines are heard, read, or both: SPEECH_BOTH, SPEECH_TEXT, SPEECH_VOICE
|
|
36
37
|
|
|
37
38
|
(procedure (SetFlag n)
|
|
38
39
|
(|= [gameFlags (/ n 16)] (<< 1 (mod n 16))))
|
package/lib/system.sh
CHANGED
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
(define SR_RESTART 4)
|
|
46
46
|
(define SR_NAME 5)
|
|
47
47
|
(define SR_TEXT 6)
|
|
48
|
+
(define SR_SPEECH 7)
|
|
48
49
|
|
|
49
50
|
;; How long a line stays up (the textSpeed global): normal (the default), slow, fast, or
|
|
50
51
|
;; until the player clicks.
|
|
@@ -53,6 +54,14 @@
|
|
|
53
54
|
(define TEXT_FAST 2)
|
|
54
55
|
(define TEXT_CLICK 3)
|
|
55
56
|
|
|
57
|
+
;; Lines with a recording (the speech global): heard and read (the default), read only, or
|
|
58
|
+
;; heard only. Lines with no recording are always shown.
|
|
59
|
+
(define SPEECH_BOTH 0)
|
|
60
|
+
(define SPEECH_TEXT 1)
|
|
61
|
+
(define SPEECH_VOICE 2)
|
|
62
|
+
;; Cycles a spoken line stays after its recording ends.
|
|
63
|
+
(define VOICE_PAUSE 20)
|
|
64
|
+
|
|
56
65
|
;; Event types, as GetEvent reports them.
|
|
57
66
|
(define EV_NULL 0)
|
|
58
67
|
(define EV_MOUSE_DOWN 1)
|
package/package.json
CHANGED
|
@@ -104,7 +104,7 @@
|
|
|
104
104
|
"frontY",
|
|
105
105
|
"backY"
|
|
106
106
|
],
|
|
107
|
-
"description": "Perspective: front% at y frontY, back% at y backY",
|
|
107
|
+
"description": "Perspective for the hero alone: front% at y frontY, back% at y backY (perspective: is simpler)",
|
|
108
108
|
"properties": {
|
|
109
109
|
"front": {
|
|
110
110
|
"type": "integer"
|
|
@@ -122,6 +122,25 @@
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
},
|
|
125
|
+
"perspective": {
|
|
126
|
+
"type": "object",
|
|
127
|
+
"additionalProperties": false,
|
|
128
|
+
"required": [
|
|
129
|
+
"horizon",
|
|
130
|
+
"fullSize"
|
|
131
|
+
],
|
|
132
|
+
"description": "The picture's camera: people are sized by how far below the horizon they stand. The hero and props that move are sized as they go.",
|
|
133
|
+
"properties": {
|
|
134
|
+
"horizon": {
|
|
135
|
+
"type": "integer",
|
|
136
|
+
"description": "The y the floor's lines meet at"
|
|
137
|
+
},
|
|
138
|
+
"fullSize": {
|
|
139
|
+
"type": "integer",
|
|
140
|
+
"description": "The y of a figure's feet where it's drawn at full size"
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
},
|
|
125
144
|
"walkable": {
|
|
126
145
|
"$ref": "#/$defs/polygon",
|
|
127
146
|
"description": "The floor: the hero stays inside"
|
|
@@ -227,6 +246,10 @@
|
|
|
227
246
|
"type": "string",
|
|
228
247
|
"pattern": "^[A-Z][A-Za-z0-9]*$",
|
|
229
248
|
"description": "A Script class of the game's: an instance of it runs on the prop, e.g. an ambient behaviour"
|
|
249
|
+
},
|
|
250
|
+
"scale": {
|
|
251
|
+
"type": "boolean",
|
|
252
|
+
"description": "Sized by the room's perspective (default: if it moves). A still prop is sized once, for where it is."
|
|
230
253
|
}
|
|
231
254
|
}
|
|
232
255
|
}
|
|
@@ -4,7 +4,8 @@ import type { Target } from "./target.ts";
|
|
|
4
4
|
* (.sca) and message text (.msg), the formats tools/mod-build.ts already builds from.
|
|
5
5
|
*
|
|
6
6
|
* room: 701
|
|
7
|
-
* hero: { at: [36, 152], enterTo: [110, 160]
|
|
7
|
+
* hero: { at: [36, 152], enterTo: [110, 160] }
|
|
8
|
+
* perspective: { horizon: 72, fullSize: 176 }
|
|
8
9
|
* walkable: [[28, 152], [60, 135], ...]
|
|
9
10
|
* features:
|
|
10
11
|
* pods: { rect: [236, 14, 310, 94] }
|
|
@@ -28,7 +29,7 @@ export interface RoomSpec {
|
|
|
28
29
|
at?: Point;
|
|
29
30
|
/** Walk here on arrival (hands off until there). */
|
|
30
31
|
enterTo?: Point;
|
|
31
|
-
/** Perspective: `front`% at y `frontY`, `back`% at y `backY
|
|
32
|
+
/** Perspective for the hero alone: `front`% at y `frontY`, `back`% at y `backY` (perspective: is simpler). */
|
|
32
33
|
scale?: {
|
|
33
34
|
front: number;
|
|
34
35
|
back: number;
|
|
@@ -36,6 +37,17 @@ export interface RoomSpec {
|
|
|
36
37
|
backY: number;
|
|
37
38
|
};
|
|
38
39
|
};
|
|
40
|
+
/**
|
|
41
|
+
* The picture's camera, for sizing people by where they stand: the horizon (the y the
|
|
42
|
+
* floor's lines meet at), and the y where a figure's feet are when it's drawn at full
|
|
43
|
+
* size. A figure's height is in proportion to how far below the horizon it stands, so it
|
|
44
|
+
* shrinks towards the back and grows past fullSize at the front. The hero and props that
|
|
45
|
+
* move are sized as they go; a still prop only with scale: true.
|
|
46
|
+
*/
|
|
47
|
+
perspective?: {
|
|
48
|
+
horizon: number;
|
|
49
|
+
fullSize: number;
|
|
50
|
+
};
|
|
39
51
|
/** The floor: a polygon the hero stays inside. */
|
|
40
52
|
walkable?: Point[];
|
|
41
53
|
/** Polygons the hero walks around. */
|
|
@@ -84,6 +96,11 @@ interface PropSpec {
|
|
|
84
96
|
sightAngle?: number;
|
|
85
97
|
/** A Script class of the game's: an instance of it runs on the prop (ambient behaviour). */
|
|
86
98
|
script?: string;
|
|
99
|
+
/**
|
|
100
|
+
* Sized by the room's perspective: by default a prop that moves is and a still one
|
|
101
|
+
* (drawn to fit its place, like someone in a chair) isn't.
|
|
102
|
+
*/
|
|
103
|
+
scale?: boolean;
|
|
87
104
|
}
|
|
88
105
|
export interface CompiledRoom {
|
|
89
106
|
number: number;
|
|
@@ -95,6 +112,32 @@ export interface CompiledRoom {
|
|
|
95
112
|
roomTalkers: boolean;
|
|
96
113
|
/** Yarn variables ($name) used: each is a flag, numbered by `options.flag`. */
|
|
97
114
|
flags: string[];
|
|
115
|
+
/** The spoken lines (not menu labels), with their message keys today. */
|
|
116
|
+
lines: SpokenLine[];
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* A line someone says. Its message key (noun, verb, cond, seq) is positional and changes
|
|
120
|
+
* when lines or things are added before it; its id (#line:) doesn't, so recordings use that.
|
|
121
|
+
*/
|
|
122
|
+
export interface SpokenLine {
|
|
123
|
+
id?: string;
|
|
124
|
+
noun: number;
|
|
125
|
+
verb: number;
|
|
126
|
+
cond: number;
|
|
127
|
+
seq: number;
|
|
128
|
+
talker: number;
|
|
129
|
+
/** As written in the Yarn (Narrator when it has none). */
|
|
130
|
+
speaker: string;
|
|
131
|
+
text: string;
|
|
132
|
+
/** The Yarn node it's in (and for a topic's answer, the topic). */
|
|
133
|
+
node: string;
|
|
134
|
+
/** What's said just before it in the same node, if anything: context for whoever records it. */
|
|
135
|
+
before?: {
|
|
136
|
+
speaker: string;
|
|
137
|
+
text: string;
|
|
138
|
+
};
|
|
139
|
+
/** file:line in the Yarn. */
|
|
140
|
+
where: string;
|
|
98
141
|
}
|
|
99
142
|
export interface CompileOptions {
|
|
100
143
|
/**
|
|
@@ -42,7 +42,7 @@ class SpecChecker {
|
|
|
42
42
|
const s = this.spec;
|
|
43
43
|
if (!s || typeof s !== "object")
|
|
44
44
|
this.fail([], "expected a mapping (room: ..., features: ...)");
|
|
45
|
-
this.known(s, [], ["room", "picture", "music", "hero", "walkable", "obstacles", "features", "exits", "props", "characters", "properties"]);
|
|
45
|
+
this.known(s, [], ["room", "picture", "music", "hero", "perspective", "walkable", "obstacles", "features", "exits", "props", "characters", "properties"]);
|
|
46
46
|
this.int(s.room, ["room"], 0, 65535);
|
|
47
47
|
if (s.picture !== undefined)
|
|
48
48
|
this.int(s.picture, ["picture"], 0, 65535);
|
|
@@ -62,6 +62,14 @@ class SpecChecker {
|
|
|
62
62
|
this.int(sc[k], ["hero", "scale", k], 0, 1000);
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
|
+
if (s.perspective !== undefined) {
|
|
66
|
+
const pv = this.map(s.perspective, ["perspective"]);
|
|
67
|
+
this.known(pv, ["perspective"], ["horizon", "fullSize"]);
|
|
68
|
+
this.int(pv.horizon, ["perspective", "horizon"], -1000, 199);
|
|
69
|
+
this.int(pv.fullSize, ["perspective", "fullSize"], 0, 1000);
|
|
70
|
+
if (pv.fullSize <= pv.horizon)
|
|
71
|
+
this.fail(["perspective", "fullSize"], "must be below the horizon (a bigger y)");
|
|
72
|
+
}
|
|
65
73
|
if (s.walkable !== undefined)
|
|
66
74
|
this.polygon(s.walkable, ["walkable"]);
|
|
67
75
|
if (s.obstacles !== undefined) {
|
|
@@ -83,7 +91,11 @@ class SpecChecker {
|
|
|
83
91
|
names.add(name);
|
|
84
92
|
const t = this.map(value, path);
|
|
85
93
|
if (group === "props") {
|
|
86
|
-
this.known(t, path, ["view", "at", "loop", "cel", "cycle", "sightAngle", "moves", "script"]);
|
|
94
|
+
this.known(t, path, ["view", "at", "loop", "cel", "cycle", "sightAngle", "moves", "script", "scale"]);
|
|
95
|
+
if (t.scale !== undefined && typeof t.scale !== "boolean")
|
|
96
|
+
this.fail([...path, "scale"], "expected true or false");
|
|
97
|
+
if (t.scale === true && s.perspective === undefined)
|
|
98
|
+
this.fail([...path, "scale"], "the room has no perspective: to size it by");
|
|
87
99
|
if (t.script !== undefined && (typeof t.script !== "string" || !/^[A-Z][A-Za-z0-9]*$/.test(t.script)))
|
|
88
100
|
this.fail([...path, "script"], "expected a class name, e.g. Scurry");
|
|
89
101
|
if (t.moves !== undefined && typeof t.moves !== "boolean")
|
|
@@ -175,6 +187,7 @@ class SpecChecker {
|
|
|
175
187
|
v.forEach((p, i) => this.point(p, [...path, i]));
|
|
176
188
|
}
|
|
177
189
|
}
|
|
190
|
+
const SCREEN_BOTTOM = 200;
|
|
178
191
|
const num = (n) => (n < 0 ? `-$${(-n).toString(16)}` : String(n));
|
|
179
192
|
function pushVal(v) {
|
|
180
193
|
if (typeof v === "number")
|
|
@@ -217,6 +230,7 @@ class RoomCompiler {
|
|
|
217
230
|
name;
|
|
218
231
|
nouns = new Map();
|
|
219
232
|
messages = [];
|
|
233
|
+
lines = [];
|
|
220
234
|
instances = [];
|
|
221
235
|
code = [];
|
|
222
236
|
strings = [];
|
|
@@ -295,7 +309,7 @@ class RoomCompiler {
|
|
|
295
309
|
this.teller(conv);
|
|
296
310
|
return {
|
|
297
311
|
number: spec.room, sca: this.assembly(), msg: this.messageText(), roomTalkers: characters.length > 0,
|
|
298
|
-
flags: [...this.flagsUsed].sort(),
|
|
312
|
+
flags: [...this.flagsUsed].sort(), lines: this.lines,
|
|
299
313
|
};
|
|
300
314
|
}
|
|
301
315
|
/** Yarn nodes -> messages and scripts; returns the arrival narration's steps. */
|
|
@@ -373,7 +387,7 @@ class RoomCompiler {
|
|
|
373
387
|
fail(`choice "${c.text}" needs at least one line under it`, c.line);
|
|
374
388
|
const topic = k + 1;
|
|
375
389
|
const label = `${node.title}: ${c.text}`;
|
|
376
|
-
this.message(conv.rootNoun, tv, topic, 1, { kind: "line", speaker: "Hero", text: c.text, line: c.line }, label);
|
|
390
|
+
this.message(conv.rootNoun, tv, topic, 1, { kind: "line", speaker: "Hero", text: c.text, line: c.line }, label, false);
|
|
377
391
|
if (c.when)
|
|
378
392
|
conv.conditional.push({ topic, when: c.when, where: `${this.yarnFile}:${c.line}` });
|
|
379
393
|
if (c.body.every((x) => x.kind === "line")) {
|
|
@@ -692,9 +706,21 @@ class RoomCompiler {
|
|
|
692
706
|
return [...this.expr(value, where), `bnt ${clear}`, ...this.flagCall("set", flag), `jmp ${done}`, `${clear}:`, ...this.flagCall("clear", flag), `${done}:`];
|
|
693
707
|
}
|
|
694
708
|
// --- Objects ---
|
|
695
|
-
message
|
|
709
|
+
/** A message; `spoken` is false for a topic menu's labels, which nobody says. */
|
|
710
|
+
message(noun, verb, cond, seq, l, label, spoken = true) {
|
|
696
711
|
const where = `${this.yarnFile}:${l.line}`;
|
|
697
|
-
|
|
712
|
+
const prev = this.messages.at(-1);
|
|
713
|
+
const m = { noun, verb, cond, seq, talker: this.talkerOf(l.speaker, where), speaker: l.speaker ?? "Narrator", text: plainText(l.text, where), label };
|
|
714
|
+
this.messages.push(m);
|
|
715
|
+
if (!spoken)
|
|
716
|
+
return;
|
|
717
|
+
const twice = l.id !== undefined && this.lines.find((x) => x.id === l.id);
|
|
718
|
+
if (twice)
|
|
719
|
+
throw new ContentError(`${where}: #line:${l.id} is already the id of ${twice.where}`);
|
|
720
|
+
this.lines.push({
|
|
721
|
+
...(l.id === undefined ? {} : { id: l.id }), noun, verb, cond, seq, talker: m.talker, speaker: m.speaker, text: m.text, node: label,
|
|
722
|
+
...(prev?.label === label ? { before: { speaker: prev.speaker, text: prev.text } } : {}), where,
|
|
723
|
+
});
|
|
698
724
|
}
|
|
699
725
|
talkerOf(speaker, where) {
|
|
700
726
|
if (speaker === undefined)
|
|
@@ -730,8 +756,14 @@ class RoomCompiler {
|
|
|
730
756
|
if (hero?.at)
|
|
731
757
|
egoCalls.push(["x", [hero.at[0]]], ["y", [hero.at[1]]]);
|
|
732
758
|
egoCalls.push(["normalize", []]);
|
|
759
|
+
// Each room sizes the hero its own way, or not at all.
|
|
760
|
+
const perspective = this.perspective();
|
|
733
761
|
if (hero?.scale)
|
|
734
762
|
egoCalls.push(["setScaler", [{ cls: "Scaler" }, hero.scale.front, hero.scale.back, hero.scale.frontY, hero.scale.backY]]);
|
|
763
|
+
else if (perspective)
|
|
764
|
+
egoCalls.push(["setScaler", [{ cls: "Scaler" }, ...perspective]]);
|
|
765
|
+
else
|
|
766
|
+
egoCalls.push(["setScaler", [0]]);
|
|
735
767
|
c.push(...send({ global: target.globals.ego }, egoCalls));
|
|
736
768
|
if (spec.music !== undefined)
|
|
737
769
|
c.push(...this.playMusic(spec.music));
|
|
@@ -740,6 +772,7 @@ class RoomCompiler {
|
|
|
740
772
|
for (const [name, p] of Object.entries(spec.props ?? {})) {
|
|
741
773
|
c.push(...send({ obj: name }, [
|
|
742
774
|
["init", []],
|
|
775
|
+
...(perspective && p.moves && p.scale !== false ? [["setScaler", [{ cls: "Scaler" }, ...perspective]]] : []),
|
|
743
776
|
...(p.cycle === "forward" ? [["setCycle", [{ cls: this.target.forwardCycle }]]] : []),
|
|
744
777
|
// A new instance of the game's Script class, running on the prop.
|
|
745
778
|
...(p.script ? [["setScript", [{ code: ["pushi #new", "push0", `class ${p.script}`, "send 4"] }]]] : []),
|
|
@@ -856,9 +889,38 @@ class RoomCompiler {
|
|
|
856
889
|
if (cases.length)
|
|
857
890
|
this.doVerb(name, "Feature", cases);
|
|
858
891
|
}
|
|
892
|
+
/**
|
|
893
|
+
* The room's perspective as Scaler arguments (front, back, frontY, backY): sizes at the
|
|
894
|
+
* nearest and farthest places anyone stands (the floor, and where the hero and props that
|
|
895
|
+
* move start). Size is a straight line in y, so the Scaler's in-between is exact; keeping
|
|
896
|
+
* to that range keeps its 16-bit arithmetic from overflowing.
|
|
897
|
+
*/
|
|
898
|
+
perspective() {
|
|
899
|
+
const pv = this.spec.perspective;
|
|
900
|
+
if (!pv)
|
|
901
|
+
return undefined;
|
|
902
|
+
const ys = [
|
|
903
|
+
...(this.spec.walkable ?? []).map((p) => p[1]),
|
|
904
|
+
...Object.values(this.spec.props ?? {}).filter((p) => p.moves).map((p) => p.at[1]),
|
|
905
|
+
...[this.spec.hero?.at, this.spec.hero?.enterTo].flatMap((p) => (p ? [p[1]] : [])),
|
|
906
|
+
].filter((y) => y > pv.horizon);
|
|
907
|
+
const backY = ys.length ? Math.min(...ys) : pv.horizon + 1, frontY = ys.length ? Math.max(...ys) : SCREEN_BOTTOM - 1;
|
|
908
|
+
const size = (y) => Math.round(((y - pv.horizon) / (pv.fullSize - pv.horizon)) * 100);
|
|
909
|
+
const [front, back] = [size(frontY), size(backY)];
|
|
910
|
+
// Scaler works out (front - back) * (y - backY) on the way.
|
|
911
|
+
if ((front - back) * (frontY - backY) > 32767)
|
|
912
|
+
throw new ContentError(`room ${this.spec.room}: perspective: sizes from ${back}% to ${front}% over ${frontY - backY} lines are too much for the Scaler`);
|
|
913
|
+
return [front, back, Math.max(frontY, backY + 1), backY];
|
|
914
|
+
}
|
|
859
915
|
prop(name, p) {
|
|
860
916
|
const cases = this.scriptedVerbs.get(name) ?? [];
|
|
861
|
-
|
|
917
|
+
// A still prop is sized once, for where it is.
|
|
918
|
+
const pv = this.spec.perspective;
|
|
919
|
+
const fixed = pv && p.scale === true && !p.moves ? Math.round(((p.at[1] - pv.horizon) / (pv.fullSize - pv.horizon)) * 128) : undefined;
|
|
920
|
+
this.instance(name, p.moves ? "Actor" : "Prop", {
|
|
921
|
+
noun: this.nouns.get(name), modNum: -1, sightAngle: p.sightAngle ?? 180, x: p.at[0], y: p.at[1], view: p.view, loop: p.loop ?? 0, cel: p.cel ?? 0,
|
|
922
|
+
...(fixed === undefined ? {} : { scaleSignal: 1, scaleX: fixed, scaleY: fixed }),
|
|
923
|
+
}, cases.length ? ["doVerb"] : []);
|
|
862
924
|
if (cases.length)
|
|
863
925
|
this.doVerb(name, p.moves ? "Actor" : "Prop", cases);
|
|
864
926
|
}
|