sci2-ts 0.0.0-stage → 0.2.2
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 +78 -0
- package/LICENSE +21 -0
- package/README.md +113 -2
- package/apps/viewer/editor.html +43 -0
- package/apps/viewer/explore.html +27 -0
- package/apps/viewer/index.html +26 -0
- package/apps/viewer/package.json +27 -0
- package/apps/viewer/play.html +26 -0
- package/apps/viewer/plugin.d.ts +18 -0
- package/apps/viewer/plugin.js +75 -0
- package/apps/viewer/plugin.ts +84 -0
- package/apps/viewer/src/adlib.d.ts +20 -0
- package/apps/viewer/src/adlib.js +55 -0
- package/apps/viewer/src/adlib.ts +62 -0
- package/apps/viewer/src/app.d.ts +5 -0
- package/apps/viewer/src/app.js +5 -0
- package/apps/viewer/src/app.ts +5 -0
- package/apps/viewer/src/asset-index.d.ts +22 -0
- package/apps/viewer/src/asset-index.js +74 -0
- package/apps/viewer/src/asset-index.ts +90 -0
- package/apps/viewer/src/audio.d.ts +28 -0
- package/apps/viewer/src/audio.js +102 -0
- package/apps/viewer/src/audio.ts +110 -0
- package/apps/viewer/src/editor-handles.d.ts +63 -0
- package/apps/viewer/src/editor-handles.js +332 -0
- package/apps/viewer/src/editor-handles.ts +368 -0
- package/apps/viewer/src/editor-languages.d.ts +23 -0
- package/apps/viewer/src/editor-languages.js +157 -0
- package/apps/viewer/src/editor-languages.ts +135 -0
- package/apps/viewer/src/editor.css +213 -0
- package/apps/viewer/src/editor.d.ts +1 -0
- package/apps/viewer/src/editor.js +393 -0
- package/apps/viewer/src/editor.ts +412 -0
- package/apps/viewer/src/explore/classes.d.ts +7 -0
- package/apps/viewer/src/explore/classes.js +63 -0
- package/apps/viewer/src/explore/classes.ts +61 -0
- package/apps/viewer/src/explore/context.d.ts +33 -0
- package/apps/viewer/src/explore/context.js +96 -0
- package/apps/viewer/src/explore/context.ts +124 -0
- package/apps/viewer/src/explore/explore.css +93 -0
- package/apps/viewer/src/explore/explore.d.ts +1 -0
- package/apps/viewer/src/explore/explore.js +102 -0
- package/apps/viewer/src/explore/explore.ts +104 -0
- package/apps/viewer/src/explore/inspect.d.ts +5 -0
- package/apps/viewer/src/explore/inspect.js +208 -0
- package/apps/viewer/src/explore/inspect.ts +279 -0
- package/apps/viewer/src/explore/live.d.ts +22 -0
- package/apps/viewer/src/explore/live.js +318 -0
- package/apps/viewer/src/explore/live.ts +365 -0
- package/apps/viewer/src/explore/map.d.ts +11 -0
- package/apps/viewer/src/explore/map.js +182 -0
- package/apps/viewer/src/explore/map.ts +193 -0
- package/apps/viewer/src/files.d.ts +21 -0
- package/apps/viewer/src/files.js +69 -0
- package/apps/viewer/src/files.ts +72 -0
- package/apps/viewer/src/game-profile.d.ts +19 -0
- package/apps/viewer/src/game-profile.js +4 -0
- package/apps/viewer/src/game-profile.ts +26 -0
- package/apps/viewer/src/main.d.ts +1 -0
- package/apps/viewer/src/main.js +473 -0
- package/apps/viewer/src/main.ts +527 -0
- package/apps/viewer/src/music.d.ts +14 -0
- package/apps/viewer/src/music.js +32 -0
- package/apps/viewer/src/music.ts +34 -0
- package/apps/viewer/src/opl-worklet.d.ts +1 -0
- package/apps/viewer/src/opl-worklet.js +47 -0
- package/apps/viewer/src/opl-worklet.ts +55 -0
- package/apps/viewer/src/overlay.d.ts +31 -0
- package/apps/viewer/src/overlay.js +103 -0
- package/apps/viewer/src/overlay.ts +123 -0
- package/apps/viewer/src/play.css +14 -0
- package/apps/viewer/src/play.d.ts +1 -0
- package/apps/viewer/src/play.js +179 -0
- package/apps/viewer/src/play.ts +177 -0
- package/apps/viewer/src/saves.d.ts +10 -0
- package/apps/viewer/src/saves.js +50 -0
- package/apps/viewer/src/saves.ts +51 -0
- package/apps/viewer/src/session.d.ts +91 -0
- package/apps/viewer/src/session.js +301 -0
- package/apps/viewer/src/session.ts +321 -0
- package/apps/viewer/src/sound-preview.d.ts +7 -0
- package/apps/viewer/src/sound-preview.js +214 -0
- package/apps/viewer/src/sound-preview.ts +228 -0
- package/apps/viewer/src/style.css +64 -0
- package/apps/viewer/vite.config.ts +25 -0
- package/bin/sci-ts.mjs +9 -0
- package/lib/994.sc +49 -0
- package/lib/995.sc +53 -0
- package/lib/996.sc +299 -0
- package/lib/997.sc +198 -0
- package/lib/998.sc +338 -0
- package/lib/999.sc +189 -0
- package/lib/resources.d.ts +2 -0
- package/lib/resources.js +84 -0
- package/lib/resources.ts +88 -0
- package/lib/system.sh +62 -0
- package/package.json +75 -4
- package/packages/content/room.schema.json +267 -0
- package/packages/content/src/index.d.ts +3 -0
- package/packages/content/src/index.js +3 -0
- package/packages/content/src/index.ts +3 -0
- package/packages/content/src/room.d.ts +111 -0
- package/packages/content/src/room.js +886 -0
- package/packages/content/src/room.ts +1011 -0
- package/packages/content/src/target.d.ts +80 -0
- package/packages/content/src/target.js +1 -0
- package/packages/content/src/target.ts +60 -0
- package/packages/content/src/yarn.d.ts +90 -0
- package/packages/content/src/yarn.js +214 -0
- package/packages/content/src/yarn.ts +261 -0
- package/packages/sci/src/analysis/game.d.ts +90 -0
- package/packages/sci/src/analysis/game.js +221 -0
- package/packages/sci/src/analysis/game.ts +305 -0
- package/packages/sci/src/analysis/runtime.d.ts +58 -0
- package/packages/sci/src/analysis/runtime.js +133 -0
- package/packages/sci/src/analysis/runtime.ts +163 -0
- package/packages/sci/src/audio/adlib.d.ts +73 -0
- package/packages/sci/src/audio/adlib.js +280 -0
- package/packages/sci/src/audio/adlib.ts +329 -0
- package/packages/sci/src/audio/index.d.ts +43 -0
- package/packages/sci/src/audio/index.js +99 -0
- package/packages/sci/src/audio/index.ts +122 -0
- package/packages/sci/src/audio/midi.d.ts +92 -0
- package/packages/sci/src/audio/midi.js +242 -0
- package/packages/sci/src/audio/midi.ts +262 -0
- package/packages/sci/src/audio/opl2.d.ts +74 -0
- package/packages/sci/src/audio/opl2.js +283 -0
- package/packages/sci/src/audio/opl2.ts +283 -0
- package/packages/sci/src/audio/sol.d.ts +32 -0
- package/packages/sci/src/audio/sol.js +81 -0
- package/packages/sci/src/audio/sol.ts +100 -0
- package/packages/sci/src/audio/write.d.ts +35 -0
- package/packages/sci/src/audio/write.js +91 -0
- package/packages/sci/src/audio/write.ts +108 -0
- package/packages/sci/src/gfx/cel.d.ts +36 -0
- package/packages/sci/src/gfx/cel.js +82 -0
- package/packages/sci/src/gfx/cel.ts +110 -0
- package/packages/sci/src/gfx/palette.d.ts +24 -0
- package/packages/sci/src/gfx/palette.js +49 -0
- package/packages/sci/src/gfx/palette.ts +56 -0
- package/packages/sci/src/gfx/pic-file.d.ts +46 -0
- package/packages/sci/src/gfx/pic-file.js +114 -0
- package/packages/sci/src/gfx/pic-file.ts +155 -0
- package/packages/sci/src/gfx/pic.d.ts +27 -0
- package/packages/sci/src/gfx/pic.js +34 -0
- package/packages/sci/src/gfx/pic.ts +53 -0
- package/packages/sci/src/gfx/quantize.d.ts +42 -0
- package/packages/sci/src/gfx/quantize.js +199 -0
- package/packages/sci/src/gfx/quantize.ts +218 -0
- package/packages/sci/src/gfx/view-file.d.ts +62 -0
- package/packages/sci/src/gfx/view-file.js +182 -0
- package/packages/sci/src/gfx/view-file.ts +233 -0
- package/packages/sci/src/gfx/view.d.ts +28 -0
- package/packages/sci/src/gfx/view.js +45 -0
- package/packages/sci/src/gfx/view.ts +62 -0
- package/packages/sci/src/index.d.ts +38 -0
- package/packages/sci/src/index.js +38 -0
- package/packages/sci/src/index.ts +38 -0
- package/packages/sci/src/motion/pathfinding.d.ts +40 -0
- package/packages/sci/src/motion/pathfinding.js +140 -0
- package/packages/sci/src/motion/pathfinding.ts +147 -0
- package/packages/sci/src/resource/archive.d.ts +16 -0
- package/packages/sci/src/resource/archive.js +51 -0
- package/packages/sci/src/resource/archive.ts +56 -0
- package/packages/sci/src/resource/lzs.d.ts +12 -0
- package/packages/sci/src/resource/lzs.js +64 -0
- package/packages/sci/src/resource/lzs.ts +66 -0
- package/packages/sci/src/resource/manager.d.ts +45 -0
- package/packages/sci/src/resource/manager.js +158 -0
- package/packages/sci/src/resource/manager.ts +184 -0
- package/packages/sci/src/resource/map.d.ts +18 -0
- package/packages/sci/src/resource/map.js +34 -0
- package/packages/sci/src/resource/map.ts +44 -0
- package/packages/sci/src/resource/types.d.ts +37 -0
- package/packages/sci/src/resource/types.js +45 -0
- package/packages/sci/src/resource/types.ts +57 -0
- package/packages/sci/src/script/assembly.d.ts +67 -0
- package/packages/sci/src/script/assembly.js +748 -0
- package/packages/sci/src/script/assembly.ts +714 -0
- package/packages/sci/src/script/compiler.d.ts +75 -0
- package/packages/sci/src/script/compiler.js +993 -0
- package/packages/sci/src/script/compiler.ts +1040 -0
- package/packages/sci/src/script/disasm.d.ts +81 -0
- package/packages/sci/src/script/disasm.js +318 -0
- package/packages/sci/src/script/disasm.ts +323 -0
- package/packages/sci/src/script/kernel-names.d.ts +6 -0
- package/packages/sci/src/script/kernel-names.js +165 -0
- package/packages/sci/src/script/kernel-names.ts +165 -0
- package/packages/sci/src/script/opcodes.d.ts +22 -0
- package/packages/sci/src/script/opcodes.js +45 -0
- package/packages/sci/src/script/opcodes.ts +72 -0
- package/packages/sci/src/script/script.d.ts +56 -0
- package/packages/sci/src/script/script.js +63 -0
- package/packages/sci/src/script/script.ts +111 -0
- package/packages/sci/src/script/sexpr.d.ts +45 -0
- package/packages/sci/src/script/sexpr.js +151 -0
- package/packages/sci/src/script/sexpr.ts +139 -0
- package/packages/sci/src/script/vocab.d.ts +15 -0
- package/packages/sci/src/script/vocab.js +44 -0
- package/packages/sci/src/script/vocab.ts +48 -0
- package/packages/sci/src/script/world.d.ts +25 -0
- package/packages/sci/src/script/world.js +65 -0
- package/packages/sci/src/script/world.ts +70 -0
- package/packages/sci/src/text/font.d.ts +37 -0
- package/packages/sci/src/text/font.js +103 -0
- package/packages/sci/src/text/font.ts +125 -0
- package/packages/sci/src/text/message.d.ts +69 -0
- package/packages/sci/src/text/message.js +171 -0
- package/packages/sci/src/text/message.ts +205 -0
- package/packages/sci/src/vm/kernels/arrays.d.ts +19 -0
- package/packages/sci/src/vm/kernels/arrays.js +182 -0
- package/packages/sci/src/vm/kernels/arrays.ts +164 -0
- package/packages/sci/src/vm/kernels/audio.d.ts +70 -0
- package/packages/sci/src/vm/kernels/audio.js +347 -0
- package/packages/sci/src/vm/kernels/audio.ts +349 -0
- package/packages/sci/src/vm/kernels/core.d.ts +5 -0
- package/packages/sci/src/vm/kernels/core.js +87 -0
- package/packages/sci/src/vm/kernels/core.ts +91 -0
- package/packages/sci/src/vm/kernels/graphics.d.ts +141 -0
- package/packages/sci/src/vm/kernels/graphics.js +467 -0
- package/packages/sci/src/vm/kernels/graphics.ts +502 -0
- package/packages/sci/src/vm/kernels/index.d.ts +8 -0
- package/packages/sci/src/vm/kernels/index.js +29 -0
- package/packages/sci/src/vm/kernels/index.ts +32 -0
- package/packages/sci/src/vm/kernels/lists.d.ts +6 -0
- package/packages/sci/src/vm/kernels/lists.js +127 -0
- package/packages/sci/src/vm/kernels/lists.ts +111 -0
- package/packages/sci/src/vm/kernels/motion.d.ts +7 -0
- package/packages/sci/src/vm/kernels/motion.js +204 -0
- package/packages/sci/src/vm/kernels/motion.ts +194 -0
- package/packages/sci/src/vm/kernels/palette.d.ts +82 -0
- package/packages/sci/src/vm/kernels/palette.js +292 -0
- package/packages/sci/src/vm/kernels/palette.ts +303 -0
- package/packages/sci/src/vm/kernels/saves.d.ts +45 -0
- package/packages/sci/src/vm/kernels/saves.js +123 -0
- package/packages/sci/src/vm/kernels/saves.ts +149 -0
- package/packages/sci/src/vm/kernels/system.d.ts +62 -0
- package/packages/sci/src/vm/kernels/system.js +110 -0
- package/packages/sci/src/vm/kernels/system.ts +128 -0
- package/packages/sci/src/vm/kernels/text.d.ts +13 -0
- package/packages/sci/src/vm/kernels/text.js +338 -0
- package/packages/sci/src/vm/kernels/text.ts +328 -0
- package/packages/sci/src/vm/memory.d.ts +138 -0
- package/packages/sci/src/vm/memory.js +168 -0
- package/packages/sci/src/vm/memory.ts +266 -0
- package/packages/sci/src/vm/replace.d.ts +11 -0
- package/packages/sci/src/vm/replace.js +14 -0
- package/packages/sci/src/vm/replace.ts +16 -0
- package/packages/sci/src/vm/savegame.d.ts +89 -0
- package/packages/sci/src/vm/savegame.js +161 -0
- package/packages/sci/src/vm/savegame.ts +188 -0
- package/packages/sci/src/vm/value.d.ts +17 -0
- package/packages/sci/src/vm/value.js +12 -0
- package/packages/sci/src/vm/value.ts +25 -0
- package/packages/sci/src/vm/vm.d.ts +155 -0
- package/packages/sci/src/vm/vm.js +1053 -0
- package/packages/sci/src/vm/vm.ts +914 -0
- package/tools/adlib-render.d.ts +1 -0
- package/tools/adlib-render.js +49 -0
- package/tools/adlib-render.ts +42 -0
- package/tools/analyze.d.ts +1 -0
- package/tools/analyze.js +25 -0
- package/tools/analyze.ts +25 -0
- package/tools/art/build.d.ts +34 -0
- package/tools/art/build.js +175 -0
- package/tools/art/build.ts +153 -0
- package/tools/art.d.ts +1 -0
- package/tools/art.js +31 -0
- package/tools/art.ts +29 -0
- package/tools/asm.d.ts +1 -0
- package/tools/asm.js +61 -0
- package/tools/asm.ts +55 -0
- package/tools/cli.d.ts +1 -0
- package/tools/cli.js +99 -0
- package/tools/cli.ts +100 -0
- package/tools/editor-server.d.ts +29 -0
- package/tools/editor-server.js +162 -0
- package/tools/editor-server.ts +176 -0
- package/tools/explore.d.ts +1 -0
- package/tools/explore.js +305 -0
- package/tools/explore.ts +250 -0
- package/tools/game/audio.d.ts +19 -0
- package/tools/game/audio.js +143 -0
- package/tools/game/audio.ts +130 -0
- package/tools/game/build.d.ts +46 -0
- package/tools/game/build.js +259 -0
- package/tools/game/build.ts +283 -0
- package/tools/game/defaults.d.ts +26 -0
- package/tools/game/defaults.js +82 -0
- package/tools/game/defaults.ts +83 -0
- package/tools/game/font.d.ts +3 -0
- package/tools/game/font.js +124 -0
- package/tools/game/font.ts +126 -0
- package/tools/game/kit.d.ts +7 -0
- package/tools/game/kit.js +7 -0
- package/tools/game/kit.ts +10 -0
- package/tools/game/new.d.ts +8 -0
- package/tools/game/new.js +39 -0
- package/tools/game/new.ts +38 -0
- package/tools/game/target.d.ts +7 -0
- package/tools/game/target.js +41 -0
- package/tools/game/target.ts +42 -0
- package/tools/game/template/resources.ts.tmpl +43 -0
- package/tools/game/template/rooms/1.room.yaml +9 -0
- package/tools/game/template/rooms/1.yarn +17 -0
- package/tools/game/template/scripts/0.sc +24 -0
- package/tools/game.d.ts +1 -0
- package/tools/game.js +77 -0
- package/tools/game.ts +70 -0
- package/tools/mod-build.d.ts +28 -0
- package/tools/mod-build.js +173 -0
- package/tools/mod-build.ts +179 -0
- package/tools/mod-target.d.ts +16 -0
- package/tools/mod-target.js +26 -0
- package/tools/mod-target.ts +32 -0
- package/tools/mod.d.ts +1 -0
- package/tools/mod.js +47 -0
- package/tools/mod.ts +45 -0
- package/tools/msg.d.ts +1 -0
- package/tools/msg.js +40 -0
- package/tools/msg.ts +36 -0
- package/tools/node-files.d.ts +3 -0
- package/tools/node-files.js +34 -0
- package/tools/node-files.ts +33 -0
- package/tools/patches.d.ts +3 -0
- package/tools/patches.js +13 -0
- package/tools/patches.ts +13 -0
- package/tools/pic-build.d.ts +9 -0
- package/tools/pic-build.js +26 -0
- package/tools/pic-build.ts +27 -0
- package/tools/pic.d.ts +1 -0
- package/tools/pic.js +64 -0
- package/tools/pic.ts +61 -0
- package/tools/play.d.ts +1 -0
- package/tools/play.js +54 -0
- package/tools/play.ts +54 -0
- package/tools/png.d.ts +16 -0
- package/tools/png.js +124 -0
- package/tools/png.ts +123 -0
- package/tools/res.d.ts +1 -0
- package/tools/res.js +48 -0
- package/tools/res.ts +50 -0
- package/tools/scenario.d.ts +1 -0
- package/tools/scenario.js +74 -0
- package/tools/scenario.ts +80 -0
- package/tools/site/build.d.ts +8 -0
- package/tools/site/build.js +110 -0
- package/tools/site/build.ts +116 -0
- package/tools/site/markdown.d.ts +11 -0
- package/tools/site/markdown.js +88 -0
- package/tools/site/markdown.ts +84 -0
- package/tools/view-build.d.ts +34 -0
- package/tools/view-build.js +47 -0
- package/tools/view-build.ts +71 -0
- package/tools/view.d.ts +1 -0
- package/tools/view.js +54 -0
- package/tools/view.ts +52 -0
|
@@ -0,0 +1,1011 @@
|
|
|
1
|
+
import { LineCounter, parseDocument, type Document } from "yaml";
|
|
2
|
+
import type { Target } from "./target.ts";
|
|
3
|
+
import { parseYarn, type YarnChoice, type YarnCommand, type YarnExpr, type YarnItem, type YarnLine, type YarnNode } from "./yarn.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A room described as data (YAML) plus what's said in it (Yarn), compiled to SCI assembly
|
|
7
|
+
* (.sca) and message text (.msg), the formats tools/mod-build.ts already builds from.
|
|
8
|
+
*
|
|
9
|
+
* room: 701
|
|
10
|
+
* hero: { at: [36, 152], enterTo: [110, 160], scale: { front: 100, back: 70, frontY: 186, backY: 122 } }
|
|
11
|
+
* walkable: [[28, 152], [60, 135], ...]
|
|
12
|
+
* features:
|
|
13
|
+
* pods: { rect: [236, 14, 310, 94] }
|
|
14
|
+
* exits:
|
|
15
|
+
* passage: { rect: [12, 84, 64, 156], approach: [40, 152], walkTo: [30, 152], to: 770 }
|
|
16
|
+
* props:
|
|
17
|
+
* wisp: { view: 701, at: [200, 96], cycle: forward }
|
|
18
|
+
*
|
|
19
|
+
* Yarn nodes named `<thing>.<verb>` are what the hero gets for that verb on that thing
|
|
20
|
+
* (`pods.look`, `wisp.do`, `room.look`); `room.enter` is narration on arrival. Lines can
|
|
21
|
+
* name a speaker (`Wisp: ...`, `Hero: ...`): characters are declared under `characters:`.
|
|
22
|
+
* A node with choices (`-> Who are you?`) is a conversation: the game's topic menu, each
|
|
23
|
+
* choice answered by the lines indented under it.
|
|
24
|
+
*/
|
|
25
|
+
export interface RoomSpec {
|
|
26
|
+
room: number;
|
|
27
|
+
picture?: number;
|
|
28
|
+
/** Music played (looping) here; left alone if it's already playing. */
|
|
29
|
+
music?: number;
|
|
30
|
+
hero?: {
|
|
31
|
+
at?: Point;
|
|
32
|
+
/** Walk here on arrival (hands off until there). */
|
|
33
|
+
enterTo?: Point;
|
|
34
|
+
/** Perspective: `front`% at y `frontY`, `back`% at y `backY`. */
|
|
35
|
+
scale?: { front: number; back: number; frontY: number; backY: number };
|
|
36
|
+
};
|
|
37
|
+
/** The floor: a polygon the hero stays inside. */
|
|
38
|
+
walkable?: Point[];
|
|
39
|
+
/** Polygons the hero walks around. */
|
|
40
|
+
obstacles?: Point[][];
|
|
41
|
+
features?: Record<string, FeatureSpec>;
|
|
42
|
+
exits?: Record<string, ExitSpec>;
|
|
43
|
+
props?: Record<string, PropSpec>;
|
|
44
|
+
/** Who speaks in this room's Yarn (besides the narrator and the hero). */
|
|
45
|
+
characters?: Record<string, CharacterSpec>;
|
|
46
|
+
/** Raw property values per object (the room is `room`), for anything not covered above. */
|
|
47
|
+
properties?: Record<string, Record<string, number>>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface CharacterSpec {
|
|
51
|
+
/** Their name in Yarn (`Old Crow: ...`), besides the key. */
|
|
52
|
+
name?: string;
|
|
53
|
+
/**
|
|
54
|
+
* Portrait view: loop 0 the bust, loop 1 mouth shapes (cel 0 closed), loop 2 eyes
|
|
55
|
+
* (cel 0 open, then blinking); every cel the same size. Without one, a plain text box.
|
|
56
|
+
*/
|
|
57
|
+
portrait?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type Point = [number, number];
|
|
61
|
+
type Rect = [number, number, number, number];
|
|
62
|
+
|
|
63
|
+
interface FeatureSpec {
|
|
64
|
+
/** left, top, right, bottom (room coordinates). */
|
|
65
|
+
rect: Rect;
|
|
66
|
+
/** Where the hero looks at; the middle of rect unless given. */
|
|
67
|
+
at?: Point;
|
|
68
|
+
approach?: Point;
|
|
69
|
+
sightAngle?: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface ExitSpec extends FeatureSpec {
|
|
73
|
+
to: number;
|
|
74
|
+
/** Walk here before leaving. */
|
|
75
|
+
walkTo?: Point;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface PropSpec {
|
|
79
|
+
view: number;
|
|
80
|
+
at: Point;
|
|
81
|
+
/** It walks or floats about in cutscenes (<<walk wisp x y>>): an Actor, not a Prop. */
|
|
82
|
+
moves?: boolean;
|
|
83
|
+
loop?: number;
|
|
84
|
+
cel?: number;
|
|
85
|
+
/** forward: loop the animation forever. */
|
|
86
|
+
cycle?: "forward";
|
|
87
|
+
sightAngle?: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface CompiledRoom {
|
|
91
|
+
number: number;
|
|
92
|
+
/** SCI assembly for tools/asm.ts. */
|
|
93
|
+
sca: string;
|
|
94
|
+
/** Message text for tools/msg.ts. */
|
|
95
|
+
msg: string;
|
|
96
|
+
/** The room defines characters: the game needs the target's support for room talkers. */
|
|
97
|
+
roomTalkers: boolean;
|
|
98
|
+
/** Yarn variables ($name) used: each is a flag, numbered by `options.flag`. */
|
|
99
|
+
flags: string[];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface CompileOptions {
|
|
103
|
+
/**
|
|
104
|
+
* The flag number for a Yarn variable. Variables are flags shared by a whole mod (and
|
|
105
|
+
* its saved games), so the caller keeps the numbering (tools/mod-build.ts: flags.yaml).
|
|
106
|
+
*/
|
|
107
|
+
flag?: (name: string) => number;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export class ContentError extends Error {}
|
|
111
|
+
|
|
112
|
+
/** Compiles `<n>.room.yaml` (+ `<n>.yarn`) source text. File names only label errors. */
|
|
113
|
+
export function compileRoom(
|
|
114
|
+
yamlSource: string,
|
|
115
|
+
yarnSource: string | undefined,
|
|
116
|
+
target: Target,
|
|
117
|
+
files: { yaml?: string; yarn?: string } = {},
|
|
118
|
+
options: CompileOptions = {},
|
|
119
|
+
): CompiledRoom {
|
|
120
|
+
const spec = readSpec(yamlSource, files.yaml ?? "<room.yaml>");
|
|
121
|
+
const nodes = yarnSource === undefined ? [] : parseYarn(yarnSource, files.yarn ?? "<yarn>");
|
|
122
|
+
const flag = options.flag ?? (() => {
|
|
123
|
+
throw new Error("variables need flag numbers (CompileOptions.flag)");
|
|
124
|
+
});
|
|
125
|
+
return new RoomCompiler(spec, nodes, target, files.yarn ?? "<yarn>", flag).compile();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- Reading and checking the YAML ------------------------------------------------
|
|
129
|
+
|
|
130
|
+
function readSpec(source: string, file: string): RoomSpec {
|
|
131
|
+
const lines = new LineCounter();
|
|
132
|
+
const doc = parseDocument(source, { lineCounter: lines, prettyErrors: true, version: "1.2" });
|
|
133
|
+
if (doc.errors.length) throw new ContentError(`${file}: ${doc.errors[0]!.message}`);
|
|
134
|
+
const at = (path: (string | number)[]) => {
|
|
135
|
+
const node = doc.getIn(path, true) as { range?: [number, number, number] } | undefined;
|
|
136
|
+
const pos = node?.range ? lines.linePos(node.range[0]) : undefined;
|
|
137
|
+
return `${file}${pos ? `:${pos.line}` : ""}: ${path.join(".") || "(top)"}`;
|
|
138
|
+
};
|
|
139
|
+
const spec = doc.toJS() as RoomSpec;
|
|
140
|
+
new SpecChecker(spec, at, doc).check();
|
|
141
|
+
return spec;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
class SpecChecker {
|
|
145
|
+
constructor(
|
|
146
|
+
readonly spec: RoomSpec,
|
|
147
|
+
readonly at: (path: (string | number)[]) => string,
|
|
148
|
+
readonly doc: Document,
|
|
149
|
+
) {}
|
|
150
|
+
|
|
151
|
+
fail(path: (string | number)[], why: string): never {
|
|
152
|
+
throw new ContentError(`${this.at(path)}: ${why}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
check(): void {
|
|
156
|
+
const s = this.spec as unknown as Record<string, unknown>;
|
|
157
|
+
if (!s || typeof s !== "object") this.fail([], "expected a mapping (room: ..., features: ...)");
|
|
158
|
+
this.known(s, [], ["room", "picture", "music", "hero", "walkable", "obstacles", "features", "exits", "props", "characters", "properties"]);
|
|
159
|
+
this.int(s.room, ["room"], 0, 65535);
|
|
160
|
+
if (s.picture !== undefined) this.int(s.picture, ["picture"], 0, 65535);
|
|
161
|
+
if (s.music !== undefined) this.int(s.music, ["music"], 0, 65535);
|
|
162
|
+
if (s.hero !== undefined) {
|
|
163
|
+
const h = this.map(s.hero, ["hero"]);
|
|
164
|
+
this.known(h, ["hero"], ["at", "enterTo", "scale"]);
|
|
165
|
+
if (h.at !== undefined) this.point(h.at, ["hero", "at"]);
|
|
166
|
+
if (h.enterTo !== undefined) this.point(h.enterTo, ["hero", "enterTo"]);
|
|
167
|
+
if (h.scale !== undefined) {
|
|
168
|
+
const sc = this.map(h.scale, ["hero", "scale"]);
|
|
169
|
+
this.known(sc, ["hero", "scale"], ["front", "back", "frontY", "backY"]);
|
|
170
|
+
for (const k of ["front", "back", "frontY", "backY"]) this.int(sc[k], ["hero", "scale", k], 0, 1000);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (s.walkable !== undefined) this.polygon(s.walkable, ["walkable"]);
|
|
174
|
+
if (s.obstacles !== undefined) {
|
|
175
|
+
if (!Array.isArray(s.obstacles)) this.fail(["obstacles"], "expected a list of polygons");
|
|
176
|
+
s.obstacles.forEach((p, i) => this.polygon(p, ["obstacles", i]));
|
|
177
|
+
}
|
|
178
|
+
const names = new Set<string>(["room"]);
|
|
179
|
+
for (const group of ["features", "exits", "props"] as const) {
|
|
180
|
+
if (s[group] === undefined) continue;
|
|
181
|
+
const things = this.map(s[group], [group]);
|
|
182
|
+
for (const [name, value] of Object.entries(things)) {
|
|
183
|
+
const path = [group, name];
|
|
184
|
+
if (!/^[A-Za-z][A-Za-z0-9]*$/.test(name)) this.fail(path, "names are letters and digits, starting with a letter");
|
|
185
|
+
if (names.has(name)) this.fail(path, `"${name}" is already used`);
|
|
186
|
+
names.add(name);
|
|
187
|
+
const t = this.map(value, path);
|
|
188
|
+
if (group === "props") {
|
|
189
|
+
this.known(t, path, ["view", "at", "loop", "cel", "cycle", "sightAngle", "moves"]);
|
|
190
|
+
if (t.moves !== undefined && typeof t.moves !== "boolean") this.fail([...path, "moves"], "expected true or false");
|
|
191
|
+
this.int(t.view, [...path, "view"], 0, 65535);
|
|
192
|
+
this.point(t.at, [...path, "at"]);
|
|
193
|
+
if (t.loop !== undefined) this.int(t.loop, [...path, "loop"], 0, 255);
|
|
194
|
+
if (t.cel !== undefined) this.int(t.cel, [...path, "cel"], 0, 255);
|
|
195
|
+
if (t.cycle !== undefined && t.cycle !== "forward") this.fail([...path, "cycle"], `unknown cycle "${String(t.cycle)}" (supported: forward)`);
|
|
196
|
+
} else {
|
|
197
|
+
const keys = ["rect", "at", "approach", "sightAngle", ...(group === "exits" ? ["to", "walkTo"] : [])];
|
|
198
|
+
this.known(t, path, keys);
|
|
199
|
+
this.rect(t.rect, [...path, "rect"]);
|
|
200
|
+
if (t.at !== undefined) this.point(t.at, [...path, "at"]);
|
|
201
|
+
if (t.approach !== undefined) this.point(t.approach, [...path, "approach"]);
|
|
202
|
+
if (group === "exits") {
|
|
203
|
+
this.int(t.to, [...path, "to"], 0, 65535);
|
|
204
|
+
if (t.walkTo !== undefined) this.point(t.walkTo, [...path, "walkTo"]);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (t.sightAngle !== undefined) this.int(t.sightAngle, [...path, "sightAngle"], 0, 360);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (s.characters !== undefined) {
|
|
211
|
+
for (const [key, value] of Object.entries(this.map(s.characters, ["characters"]))) {
|
|
212
|
+
const path = ["characters", key];
|
|
213
|
+
if (!/^[A-Za-z][A-Za-z0-9]*$/.test(key)) this.fail(path, "names are letters and digits, starting with a letter");
|
|
214
|
+
if (["narrator", "hero"].includes(key.toLowerCase())) this.fail(path, `"${key}" is built in`);
|
|
215
|
+
const c = this.map(value ?? {}, path);
|
|
216
|
+
this.known(c, path, ["name", "portrait"]);
|
|
217
|
+
if (c.portrait !== undefined) this.int(c.portrait, [...path, "portrait"], 0, 65535);
|
|
218
|
+
if (c.name !== undefined && (typeof c.name !== "string" || !/^[\x20-\x7e]{1,40}$/.test(c.name))) {
|
|
219
|
+
this.fail([...path, "name"], "expected plain text, up to 40 characters");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (s.properties !== undefined) {
|
|
224
|
+
const props = this.map(s.properties, ["properties"]);
|
|
225
|
+
for (const [name, values] of Object.entries(props)) {
|
|
226
|
+
if (!names.has(name)) this.fail(["properties", name], `no feature, exit or prop called "${name}" (the room is "room")`);
|
|
227
|
+
for (const [k, v] of Object.entries(this.map(values, ["properties", name]))) this.int(v, ["properties", name, k], -32768, 65535);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
known(o: Record<string, unknown>, path: (string | number)[], keys: string[]): void {
|
|
233
|
+
for (const k of Object.keys(o)) if (!keys.includes(k)) this.fail([...path, k], `unknown key (expected one of: ${keys.join(", ")})`);
|
|
234
|
+
}
|
|
235
|
+
map(v: unknown, path: (string | number)[]): Record<string, unknown> {
|
|
236
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) this.fail(path, "expected a mapping (key: value)");
|
|
237
|
+
return v as Record<string, unknown>;
|
|
238
|
+
}
|
|
239
|
+
int(v: unknown, path: (string | number)[], min: number, max: number): void {
|
|
240
|
+
if (v === undefined) this.fail(path, "missing");
|
|
241
|
+
if (!Number.isInteger(v) || (v as number) < min || (v as number) > max) this.fail(path, `expected a whole number from ${min} to ${max}, got ${JSON.stringify(v)}`);
|
|
242
|
+
}
|
|
243
|
+
point(v: unknown, path: (string | number)[]): void {
|
|
244
|
+
if (!Array.isArray(v) || v.length !== 2) this.fail(path, "expected [x, y]");
|
|
245
|
+
v.forEach((n, i) => this.int(n, [...path, i], -1000, 1000));
|
|
246
|
+
}
|
|
247
|
+
rect(v: unknown, path: (string | number)[]): void {
|
|
248
|
+
if (!Array.isArray(v) || v.length !== 4) this.fail(path, "expected [left, top, right, bottom]");
|
|
249
|
+
v.forEach((n, i) => this.int(n, [...path, i], -1000, 1000));
|
|
250
|
+
if (v[0] > v[2] || v[1] > v[3]) this.fail(path, "left must be <= right and top <= bottom");
|
|
251
|
+
}
|
|
252
|
+
polygon(v: unknown, path: (string | number)[]): void {
|
|
253
|
+
if (!Array.isArray(v) || v.length < 3) this.fail(path, "expected a list of at least 3 [x, y] points");
|
|
254
|
+
v.forEach((p, i) => this.point(p, [...path, i]));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// --- Code generation ---------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
/** A value pushed as a send argument. */
|
|
261
|
+
type Val = number | { obj: string } | { cls: string } | "self" | { code: string[] };
|
|
262
|
+
/** Who receives a send. */
|
|
263
|
+
type To = { global: number } | { obj: string } | { cls: string } | "self" | { superOf: string };
|
|
264
|
+
|
|
265
|
+
const num = (n: number) => (n < 0 ? `-$${(-n).toString(16)}` : String(n));
|
|
266
|
+
|
|
267
|
+
function pushVal(v: Val): string[] {
|
|
268
|
+
if (typeof v === "number") return [v === 0 ? "push0" : v === 1 ? "push1" : v === 2 ? "push2" : `pushi ${num(v)}`];
|
|
269
|
+
if (v === "self") return ["pushSelf"];
|
|
270
|
+
if ("obj" in v) return [`lofsa @${v.obj}`, "push"];
|
|
271
|
+
if ("cls" in v) return [`class ${v.cls}`, "push"];
|
|
272
|
+
return [...v.code, "push"];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** `to sel1: args..., sel2: args...` as one send. */
|
|
276
|
+
function send(to: To, calls: [selector: string, args: Val[]][]): string[] {
|
|
277
|
+
const out: string[] = [];
|
|
278
|
+
let words = 0;
|
|
279
|
+
for (const [sel, args] of calls) {
|
|
280
|
+
out.push(`pushi #${sel}`, ...pushVal(args.length));
|
|
281
|
+
for (const a of args) out.push(...pushVal(a));
|
|
282
|
+
words += 2 + args.length;
|
|
283
|
+
}
|
|
284
|
+
const bytes = words * 2;
|
|
285
|
+
if (to === "self") out.push(`self ${bytes}`);
|
|
286
|
+
else if ("superOf" in to) out.push(`super ${to.superOf} ${bytes}`);
|
|
287
|
+
else {
|
|
288
|
+
out.push("global" in to ? `lag ${to.global}` : "obj" in to ? `lofsa @${to.obj}` : `class ${to.cls}`);
|
|
289
|
+
out.push(`send ${bytes}`);
|
|
290
|
+
}
|
|
291
|
+
return out;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
interface Message {
|
|
295
|
+
noun: number;
|
|
296
|
+
verb: number;
|
|
297
|
+
cond: number;
|
|
298
|
+
seq: number;
|
|
299
|
+
talker: number;
|
|
300
|
+
text: string;
|
|
301
|
+
label: string;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** A topic menu on a thing: its labels at rootNoun/verb/cond, answers at sayNoun/verb/cond. */
|
|
305
|
+
interface Conversation {
|
|
306
|
+
thing: string;
|
|
307
|
+
/** The verb that opens it (usually Talk). */
|
|
308
|
+
opensWith: number;
|
|
309
|
+
rootNoun: number;
|
|
310
|
+
sayNoun: number;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** A Script's states: each runs some code and cues the script (directly or when done). */
|
|
314
|
+
type State = string[];
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* A step of a compiled Yarn sequence, run by a Script's changeState: `say` and `async`
|
|
318
|
+
* cue the script when done (the next step starts a new state); labels start a state too,
|
|
319
|
+
* and jumping to one sets `state` and cues.
|
|
320
|
+
*/
|
|
321
|
+
type Instr =
|
|
322
|
+
| { op: "say"; noun: number; verb: number; cond: number }
|
|
323
|
+
| { op: "async"; code: string[] }
|
|
324
|
+
| { op: "code"; code: string[] }
|
|
325
|
+
| { op: "set"; name: string; value: YarnExpr; where: string }
|
|
326
|
+
| { op: "unless"; when: YarnExpr; label: string; where: string }
|
|
327
|
+
| { op: "jump"; label: string }
|
|
328
|
+
| { op: "label"; name: string };
|
|
329
|
+
|
|
330
|
+
/** A topic menu on a thing: its labels at rootNoun/verb/cond, answers at sayNoun/verb/cond. */
|
|
331
|
+
interface Conversation {
|
|
332
|
+
thing: string;
|
|
333
|
+
/** The verb that opens it (usually Talk). */
|
|
334
|
+
opensWith: number;
|
|
335
|
+
rootNoun: number;
|
|
336
|
+
sayNoun: number;
|
|
337
|
+
/** Topics offered only when a condition holds. */
|
|
338
|
+
conditional: { topic: number; when: YarnExpr; where: string }[];
|
|
339
|
+
/** Topics answered by a script (their bodies set flags or branch). */
|
|
340
|
+
scripted: { topic: number; script: string }[];
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
class RoomCompiler {
|
|
344
|
+
readonly name: string;
|
|
345
|
+
readonly nouns = new Map<string, number>();
|
|
346
|
+
readonly messages: Message[] = [];
|
|
347
|
+
readonly instances: string[] = [];
|
|
348
|
+
readonly code: string[] = [];
|
|
349
|
+
readonly strings: string[] = [];
|
|
350
|
+
readonly conversations: Conversation[] = [];
|
|
351
|
+
/** Characters by lower-case key and name -> talker number. */
|
|
352
|
+
readonly talkers = new Map<string, number>();
|
|
353
|
+
/** Verbs whose Yarn sets flags or branches, per thing: verb -> Script. */
|
|
354
|
+
readonly scriptedVerbs = new Map<string, [verb: number, script: string][]>();
|
|
355
|
+
/** Scripts compiled from Yarn: name, steps, and what ends them. */
|
|
356
|
+
readonly sequences: { name: string; steps: Instr[]; end: string[] }[] = [];
|
|
357
|
+
readonly flagsUsed = new Set<string>();
|
|
358
|
+
private nextNoun = 0;
|
|
359
|
+
private nextLabel = 0;
|
|
360
|
+
|
|
361
|
+
constructor(
|
|
362
|
+
readonly spec: RoomSpec,
|
|
363
|
+
readonly nodes: YarnNode[],
|
|
364
|
+
readonly target: Target,
|
|
365
|
+
readonly yarnFile: string,
|
|
366
|
+
readonly flagNumber: (name: string) => number,
|
|
367
|
+
) {
|
|
368
|
+
this.name = `rm${spec.room}`;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
compile(): CompiledRoom {
|
|
372
|
+
const { spec, target } = this;
|
|
373
|
+
const g = target.globals;
|
|
374
|
+
// Nouns: the room first, then everything clickable in the order written.
|
|
375
|
+
this.nouns.set("room", 1);
|
|
376
|
+
for (const name of [...Object.keys(spec.features ?? {}), ...Object.keys(spec.exits ?? {}), ...Object.keys(spec.props ?? {})]) {
|
|
377
|
+
this.nouns.set(name, this.nouns.size + 1);
|
|
378
|
+
}
|
|
379
|
+
this.nextNoun = this.nouns.size + 1;
|
|
380
|
+
const characters = Object.entries(spec.characters ?? {});
|
|
381
|
+
characters.forEach(([key, c], i) => {
|
|
382
|
+
const talker = target.firstRoomTalker + i;
|
|
383
|
+
this.talkers.set(key.toLowerCase(), talker);
|
|
384
|
+
if (c?.name) this.talkers.set(c.name.toLowerCase(), talker);
|
|
385
|
+
});
|
|
386
|
+
const enter = this.readNodes();
|
|
387
|
+
|
|
388
|
+
// Arrival: walk in (PolyPath cues the script), then room.enter, then hands back.
|
|
389
|
+
const enterSteps: Instr[] = [];
|
|
390
|
+
if (spec.hero?.enterTo) {
|
|
391
|
+
enterSteps.push({ op: "async", code: send({ global: g.ego }, [["setMotion", [{ cls: "PolyPath" }, ...spec.hero.enterTo, "self"]]]) });
|
|
392
|
+
}
|
|
393
|
+
enterSteps.push(...enter);
|
|
394
|
+
if (enterSteps.length) {
|
|
395
|
+
this.sequences.unshift({
|
|
396
|
+
name: "sEnter",
|
|
397
|
+
steps: [{ op: "code", code: send({ global: g.game }, [["handsOff", []]]) }, ...enterSteps],
|
|
398
|
+
end: [...send({ global: g.game }, [["handsOn", []]]), ...send("self", [["dispose", []]])],
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const exitScripts = new Map<string, string>();
|
|
403
|
+
for (const [name, exit] of Object.entries(spec.exits ?? {})) {
|
|
404
|
+
const script = `sExit${cap(name)}`;
|
|
405
|
+
exitScripts.set(name, script);
|
|
406
|
+
const leave = send({ global: g.curRoom }, [["newRoom", [exit.to]]]);
|
|
407
|
+
this.script(script, exit.walkTo
|
|
408
|
+
? [
|
|
409
|
+
[...send({ global: g.game }, [["handsOff", []]]), ...send({ global: g.ego }, [["setMotion", [{ cls: "PolyPath" }, ...exit.walkTo, "self"]]])],
|
|
410
|
+
leave,
|
|
411
|
+
]
|
|
412
|
+
: [[...send({ global: g.game }, [["handsOff", []]]), ...leave]]);
|
|
413
|
+
}
|
|
414
|
+
for (const seq of this.sequences) this.sequenceScript(seq.name, seq.steps, seq.end);
|
|
415
|
+
|
|
416
|
+
this.roomInstance(enterSteps.length > 0, characters.length > 0);
|
|
417
|
+
for (const [name, f] of Object.entries(spec.features ?? {})) this.feature(name, f);
|
|
418
|
+
for (const [name, e] of Object.entries(spec.exits ?? {})) this.feature(name, e, exitScripts.get(name));
|
|
419
|
+
for (const [name, p] of Object.entries(spec.props ?? {})) this.prop(name, p);
|
|
420
|
+
for (const [key, c] of characters) this.character(key, c ?? {});
|
|
421
|
+
for (const conv of this.conversations) this.teller(conv);
|
|
422
|
+
|
|
423
|
+
return {
|
|
424
|
+
number: spec.room, sca: this.assembly(), msg: this.messageText(), roomTalkers: characters.length > 0,
|
|
425
|
+
flags: [...this.flagsUsed].sort(),
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Yarn nodes -> messages and scripts; returns the arrival narration's steps. */
|
|
430
|
+
private readNodes(): Instr[] {
|
|
431
|
+
let enter: Instr[] = [];
|
|
432
|
+
const verbNames = Object.keys(this.target.verbs);
|
|
433
|
+
for (const node of this.nodes) {
|
|
434
|
+
const fail = (why: string, line = node.line): never => {
|
|
435
|
+
throw new ContentError(`${this.yarnFile}:${line}: node "${node.title}": ${why}`);
|
|
436
|
+
};
|
|
437
|
+
const m = /^([A-Za-z][A-Za-z0-9]*)\.([A-Za-z][A-Za-z0-9]*)$/.exec(node.title);
|
|
438
|
+
if (!m) fail("titles are <thing>.<verb>, e.g. pods.look or room.enter");
|
|
439
|
+
const [, thing, verbName] = m!;
|
|
440
|
+
const noun = this.nouns.get(thing!);
|
|
441
|
+
if (noun === undefined) fail(`no feature, exit or prop called "${thing}" (the room itself is "room")`);
|
|
442
|
+
let verb: number;
|
|
443
|
+
if (verbName === "enter") {
|
|
444
|
+
if (thing !== "room") fail("only the room has .enter (narration on arrival)");
|
|
445
|
+
verb = this.target.narrationVerb;
|
|
446
|
+
} else {
|
|
447
|
+
const v = this.target.verbs[verbName!.toLowerCase()];
|
|
448
|
+
if (v === undefined) fail(`unknown verb "${verbName}" (known: ${verbNames.slice(0, 12).join(", ")}${verbNames.length > 12 ? ", ..." : ""})`);
|
|
449
|
+
verb = v!;
|
|
450
|
+
}
|
|
451
|
+
if (!node.body.length) fail("has no lines");
|
|
452
|
+
const choices = node.body.filter((x): x is YarnChoice => x.kind === "choice");
|
|
453
|
+
|
|
454
|
+
if (!choices.length) {
|
|
455
|
+
const plain = node.body.every((x) => x.kind === "line");
|
|
456
|
+
if (verbName === "enter") {
|
|
457
|
+
// Plain narration keeps cond 0, like any other message for the room.
|
|
458
|
+
if (plain) {
|
|
459
|
+
node.body.forEach((l, i) => this.message(noun!, verb, 0, i + 1, l as YarnLine, node.title));
|
|
460
|
+
enter = [{ op: "say", noun: noun!, verb, cond: 0 }];
|
|
461
|
+
} else enter = this.steps(node.body, noun!, verb, counter(1), node.title);
|
|
462
|
+
} else if (plain) {
|
|
463
|
+
// The game's doVerb says noun/verb messages by itself: no code at all.
|
|
464
|
+
node.body.forEach((l, i) => this.message(noun!, verb, 0, i + 1, l as YarnLine, node.title));
|
|
465
|
+
} else {
|
|
466
|
+
const script = `s${cap(thing!)}${cap(verbName!)}`;
|
|
467
|
+
this.sequences.push({
|
|
468
|
+
name: script,
|
|
469
|
+
steps: [{ op: "code", code: send({ global: this.target.globals.game }, [["handsOff", []]]) }, ...this.steps(node.body, noun!, verb, counter(1), node.title)],
|
|
470
|
+
end: [...send({ global: this.target.globals.game }, [["handsOn", []]]), ...send("self", [["dispose", []]])],
|
|
471
|
+
});
|
|
472
|
+
this.scriptedVerbs.set(thing!, [...(this.scriptedVerbs.get(thing!) ?? []), [verb, script]]);
|
|
473
|
+
}
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// A conversation: the target's topic menu (its Teller). Each choice is a topic (the hero's line), the
|
|
478
|
+
// lines under it the answer.
|
|
479
|
+
const stray = node.body.find((x) => x.kind !== "choice");
|
|
480
|
+
if (stray) fail("lines or commands outside choices in a conversation aren't supported yet (put them under a choice)", stray.line);
|
|
481
|
+
if (thing === "room" || verbName === "enter") fail("conversations need a feature or prop to talk to");
|
|
482
|
+
if (choices.length > 140) fail("too many choices (140 at most)");
|
|
483
|
+
const conv: Conversation = { thing: thing!, opensWith: verb, rootNoun: this.nextNoun++, sayNoun: this.nextNoun++, conditional: [], scripted: [] };
|
|
484
|
+
this.conversations.push(conv);
|
|
485
|
+
const tv = this.target.teller.verb;
|
|
486
|
+
let blockNoun: number | undefined;
|
|
487
|
+
const blockConds = counter(1);
|
|
488
|
+
choices.forEach((c, k) => {
|
|
489
|
+
if (!c.body.length) fail(`choice "${c.text}" needs at least one line under it`, c.line);
|
|
490
|
+
const topic = k + 1;
|
|
491
|
+
const label = `${node.title}: ${c.text}`;
|
|
492
|
+
this.message(conv.rootNoun, tv, topic, 1, { kind: "line", speaker: "Hero", text: c.text, line: c.line }, label);
|
|
493
|
+
if (c.when) conv.conditional.push({ topic, when: c.when, where: `${this.yarnFile}:${c.line}` });
|
|
494
|
+
if (c.body.every((x) => x.kind === "line")) {
|
|
495
|
+
c.body.forEach((l, j) => this.message(conv.sayNoun, tv, topic, j + 1, l as YarnLine, label));
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
// Answered by a script, run by the room with the teller as its caller: when it's
|
|
499
|
+
// done it cues the teller, which shows the menu again.
|
|
500
|
+
blockNoun ??= this.nextNoun++;
|
|
501
|
+
const script = `s${cap(thing!)}Topic${topic}`;
|
|
502
|
+
conv.scripted.push({ topic, script });
|
|
503
|
+
this.sequences.push({ name: script, steps: this.steps(c.body, blockNoun, tv, blockConds, label), end: send("self", [["dispose", []]]) });
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
return enter;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** Yarn items -> steps; runs of lines become one message tuple (one say) each. */
|
|
510
|
+
private steps(items: YarnItem[], noun: number, verb: number, conds: () => number, label: string): Instr[] {
|
|
511
|
+
const out: Instr[] = [];
|
|
512
|
+
let lines: YarnLine[] = [];
|
|
513
|
+
const flush = () => {
|
|
514
|
+
if (!lines.length) return;
|
|
515
|
+
const cond = conds();
|
|
516
|
+
if (cond > 255) throw new ContentError(`${this.yarnFile}:${lines[0]!.line}: node "${label}" has too many separate passages (255 at most)`);
|
|
517
|
+
lines.forEach((l, i) => this.message(noun, verb, cond, i + 1, l, label));
|
|
518
|
+
out.push({ op: "say", noun, verb, cond });
|
|
519
|
+
lines = [];
|
|
520
|
+
};
|
|
521
|
+
for (const item of items) {
|
|
522
|
+
if (item.kind === "line") {
|
|
523
|
+
lines.push(item);
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
flush();
|
|
527
|
+
const where = `${this.yarnFile}:${item.line}`;
|
|
528
|
+
if (item.kind === "set") out.push({ op: "set", name: item.name, value: item.value, where });
|
|
529
|
+
else if (item.kind === "if") {
|
|
530
|
+
const end = this.label();
|
|
531
|
+
for (const branch of item.branches) {
|
|
532
|
+
const next = this.label();
|
|
533
|
+
if (branch.when) out.push({ op: "unless", when: branch.when, label: next, where });
|
|
534
|
+
out.push(...this.steps(branch.body, noun, verb, conds, label), { op: "jump", label: end }, { op: "label", name: next });
|
|
535
|
+
}
|
|
536
|
+
out.push({ op: "label", name: end });
|
|
537
|
+
} else if (item.kind === "command") out.push(...this.command(item, where));
|
|
538
|
+
else throw new ContentError(`${where}: choices can only be the whole of a conversation node`);
|
|
539
|
+
}
|
|
540
|
+
flush();
|
|
541
|
+
return out;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Cutscene commands. Those that take time (walking, turning, waiting, a one-shot
|
|
546
|
+
* animation) cue the script when done; the rest happen at once.
|
|
547
|
+
*/
|
|
548
|
+
private command(cmd: YarnCommand, where: string): Instr[] {
|
|
549
|
+
const g = this.target.globals;
|
|
550
|
+
const { name, args } = cmd;
|
|
551
|
+
const fail = (why: string): never => {
|
|
552
|
+
throw new ContentError(`${where}: <<${name}${args.length ? ` ${args.join(" ")}` : ""}>>: ${why}`);
|
|
553
|
+
};
|
|
554
|
+
const arity = (n: number, usage: string) => args.length !== n && fail(`expected <<${name} ${usage}>>`);
|
|
555
|
+
const int = (s: string | undefined, what: string, min = -1000, max = 1000) => {
|
|
556
|
+
const n = Number(s);
|
|
557
|
+
if (!Number.isInteger(n) || n < min || n > max) fail(`${what} must be a whole number from ${min} to ${max}`);
|
|
558
|
+
return n;
|
|
559
|
+
};
|
|
560
|
+
const props = this.spec.props ?? {};
|
|
561
|
+
/** hero, or a prop (`moving`: one with moves: true). */
|
|
562
|
+
const who = (s: string | undefined, moving: boolean): To => {
|
|
563
|
+
if (s === "hero") return { global: g.ego };
|
|
564
|
+
const p = s === undefined ? undefined : props[s];
|
|
565
|
+
if (!p) fail(`"${s}" isn't hero or a prop of this room`);
|
|
566
|
+
if (moving && !p!.moves) fail(`"${s}" doesn't move: give it moves: true in the room's YAML`);
|
|
567
|
+
return { obj: s! };
|
|
568
|
+
};
|
|
569
|
+
const thingAt = (s: string): Point | undefined => {
|
|
570
|
+
const p = props[s];
|
|
571
|
+
if (p) return p.at;
|
|
572
|
+
const f = this.spec.features?.[s] ?? this.spec.exits?.[s];
|
|
573
|
+
if (!f) return undefined;
|
|
574
|
+
return f.at ?? [Math.round((f.rect[0] + f.rect[2]) / 2), Math.round((f.rect[1] + f.rect[3]) / 2)];
|
|
575
|
+
};
|
|
576
|
+
switch (name) {
|
|
577
|
+
case "walk": {
|
|
578
|
+
arity(3, "hero|<prop> x y");
|
|
579
|
+
const to = who(args[0], true);
|
|
580
|
+
const motion = args[0] === "hero" ? "PolyPath" : "MoveTo";
|
|
581
|
+
return [{ op: "async", code: send(to, [["setMotion", [{ cls: motion }, int(args[1], "x"), int(args[2], "y"), "self"]]]) }];
|
|
582
|
+
}
|
|
583
|
+
case "face": {
|
|
584
|
+
if (args.length < 2 || args.length > 3) fail("expected <<face hero|<prop> up|down|left|right|<thing>|x y>>");
|
|
585
|
+
const to = who(args[0], true);
|
|
586
|
+
const directions: Record<string, number> = { up: 0, upright: 45, right: 90, downright: 135, down: 180, downleft: 225, left: 270, upleft: 315 };
|
|
587
|
+
let heading: Val;
|
|
588
|
+
// Props (which may have moved) are faced where they are now; features where they're drawn.
|
|
589
|
+
const pos = (of: To, sel: string) => [...send(of, [[sel, []]]), "push"];
|
|
590
|
+
const target: string[] | undefined = args.length === 3
|
|
591
|
+
? [...pushVal(int(args[1], "x")), ...pushVal(int(args[2], "y"))]
|
|
592
|
+
: props[args[1]!]
|
|
593
|
+
? [...pos({ obj: args[1]! }, "x"), ...pos({ obj: args[1]! }, "y")]
|
|
594
|
+
: thingAt(args[1]!)?.flatMap((n) => pushVal(n));
|
|
595
|
+
if (args.length === 2 && directions[args[1]!] !== undefined) heading = directions[args[1]!]!;
|
|
596
|
+
else if (target) {
|
|
597
|
+
// GetAngle from where it stands now to the target.
|
|
598
|
+
heading = { code: ["pushi 4", ...pos(to, "x"), ...pos(to, "y"), ...target, "callk GetAngle 8"] };
|
|
599
|
+
} else {
|
|
600
|
+
return fail(`expected a direction (${Object.keys(directions).join(", ")}), a thing of this room, or x y`);
|
|
601
|
+
}
|
|
602
|
+
return [{ op: "async", code: send(to, [["setHeading", [heading, "self"]]]) }];
|
|
603
|
+
}
|
|
604
|
+
case "wait": {
|
|
605
|
+
arity(1, "seconds");
|
|
606
|
+
const secs = Number(args[0]);
|
|
607
|
+
if (!(secs > 0 && secs <= 600)) fail("seconds must be a number from 0 to 600 (e.g. 1.5)");
|
|
608
|
+
return [{ op: "async", code: [`ldi ${Math.max(1, Math.round(secs * 60))}`, "aTop ticks"] }];
|
|
609
|
+
}
|
|
610
|
+
case "hide":
|
|
611
|
+
case "show":
|
|
612
|
+
arity(1, "hero|<prop>");
|
|
613
|
+
return [{ op: "code", code: send(who(args[0], false), [[name, []]]) }];
|
|
614
|
+
case "loop":
|
|
615
|
+
case "cel":
|
|
616
|
+
arity(2, `hero|<prop> ${name}`);
|
|
617
|
+
return [{ op: "code", code: send(who(args[0], false), [[name === "loop" ? "setLoop" : "setCel", [int(args[1], name, 0, 255)]]]) }];
|
|
618
|
+
case "animate": {
|
|
619
|
+
arity(2, "<prop> once|forever");
|
|
620
|
+
const to = who(args[0], false);
|
|
621
|
+
if (args[1] === "once") return [{ op: "async", code: send(to, [["setCel", [0]], ["setCycle", [{ cls: "End" }, "self"]]]) }];
|
|
622
|
+
if (args[1] === "forever") return [{ op: "code", code: send(to, [["setCycle", [{ cls: this.target.forwardCycle }]]]) }];
|
|
623
|
+
return fail("expected once or forever");
|
|
624
|
+
}
|
|
625
|
+
case "stop":
|
|
626
|
+
arity(1, "<prop>");
|
|
627
|
+
return [{ op: "code", code: send(who(args[0], false), [["setCycle", [0]]]) }];
|
|
628
|
+
case "music":
|
|
629
|
+
arity(1, "number|stop");
|
|
630
|
+
return [{ op: "code", code: args[0] === "stop" ? send({ global: g.music }, [["stop", []]]) : this.playMusic(int(args[0], "music", 0, 65535)) }];
|
|
631
|
+
case "sound": {
|
|
632
|
+
if (args.length < 1 || args.length > 2 || (args[1] !== undefined && args[1] !== "wait")) fail("expected <<sound number>> or <<sound number wait>>");
|
|
633
|
+
const n = int(args[0], "sound", 0, 65535);
|
|
634
|
+
// wait: the sound cues the script when it ends.
|
|
635
|
+
return args[1] === "wait"
|
|
636
|
+
? [{ op: "async", code: send({ global: g.sound }, [["number", [n]], ["loop", [1]], ["play", ["self"]]]) }]
|
|
637
|
+
: [{ op: "code", code: send({ global: g.sound }, [["number", [n]], ["loop", [1]], ["play", []]]) }];
|
|
638
|
+
}
|
|
639
|
+
case "room":
|
|
640
|
+
arity(1, "number");
|
|
641
|
+
return [{ op: "code", code: send({ global: g.curRoom }, [["newRoom", [int(args[0], "room", 0, 65535)]]]) }];
|
|
642
|
+
default:
|
|
643
|
+
return fail("unknown command (known: walk, face, wait, hide, show, loop, cel, animate, stop, music, sound, room; and set, if)");
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** Loop music n, unless it's what's playing already. */
|
|
648
|
+
private playMusic(n: number): string[] {
|
|
649
|
+
const music = { global: this.target.globals.music };
|
|
650
|
+
const playing = this.label();
|
|
651
|
+
return [
|
|
652
|
+
...send(music, [["number", []]]), "push", `ldi ${n}`, "eq?", `bt ${playing}`,
|
|
653
|
+
...send(music, [["number", [n]], ["setLoop", [-1]], ["play", []]]), `${playing}:`,
|
|
654
|
+
];
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
private label(): string {
|
|
658
|
+
return `.c${this.nextLabel++}`;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** A Script running `steps`, then `end`. */
|
|
662
|
+
private sequenceScript(name: string, steps: Instr[], end: string[]): void {
|
|
663
|
+
// Where states begin: the start, after each say/async, and at labels.
|
|
664
|
+
const labelState = new Map<string, number>();
|
|
665
|
+
let state = 0, empty = true;
|
|
666
|
+
for (const step of steps) {
|
|
667
|
+
if (step.op === "label") {
|
|
668
|
+
if (!empty) (state++, (empty = true));
|
|
669
|
+
labelState.set(step.name, state);
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
empty = false;
|
|
673
|
+
if (step.op === "say" || step.op === "async") (state++, (empty = true));
|
|
674
|
+
}
|
|
675
|
+
// Jumping sets the state and cues; the rest of this state's code must not run.
|
|
676
|
+
const jump = (label: string) => [`ldi ${labelState.get(label)! - 1}`, "aTop state", ...send("self", [["cue", []]]), `jmp .${name}_done`];
|
|
677
|
+
const states: State[] = [];
|
|
678
|
+
let code: string[] = [];
|
|
679
|
+
empty = true;
|
|
680
|
+
const close = () => {
|
|
681
|
+
states.push(code);
|
|
682
|
+
code = [];
|
|
683
|
+
empty = true;
|
|
684
|
+
};
|
|
685
|
+
for (const step of steps) {
|
|
686
|
+
switch (step.op) {
|
|
687
|
+
case "label":
|
|
688
|
+
if (!empty) (code.push(...jump(step.name)), close());
|
|
689
|
+
break;
|
|
690
|
+
case "say":
|
|
691
|
+
code.push(...send({ global: this.target.globals.messager }, [["say", [step.noun, step.verb, step.cond, 0, "self"]]]));
|
|
692
|
+
close();
|
|
693
|
+
break;
|
|
694
|
+
case "async":
|
|
695
|
+
code.push(...step.code);
|
|
696
|
+
close();
|
|
697
|
+
break;
|
|
698
|
+
case "code":
|
|
699
|
+
code.push(...step.code);
|
|
700
|
+
empty = false;
|
|
701
|
+
break;
|
|
702
|
+
case "set":
|
|
703
|
+
code.push(...this.setFlag(step.name, step.value, step.where));
|
|
704
|
+
empty = false;
|
|
705
|
+
break;
|
|
706
|
+
case "unless": {
|
|
707
|
+
const holds = this.label();
|
|
708
|
+
code.push(...this.expr(step.when, step.where), `bt ${holds}`, ...jump(step.label), `${holds}:`);
|
|
709
|
+
empty = false;
|
|
710
|
+
break;
|
|
711
|
+
}
|
|
712
|
+
case "jump":
|
|
713
|
+
code.push(...jump(step.label));
|
|
714
|
+
empty = false;
|
|
715
|
+
break;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
code.push(...end);
|
|
719
|
+
states.push(code);
|
|
720
|
+
this.script(name, states);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// --- Flags ---
|
|
724
|
+
|
|
725
|
+
private flag(name: string, where: string): number {
|
|
726
|
+
this.flagsUsed.add(name);
|
|
727
|
+
try {
|
|
728
|
+
return this.flagNumber(name);
|
|
729
|
+
} catch (e) {
|
|
730
|
+
throw new ContentError(`${where}: $${name}: ${(e as Error).message}`);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
private flagCall(kind: "set" | "clear" | "test", flag: number): string[] {
|
|
735
|
+
const f = this.target.flags;
|
|
736
|
+
const call = f.script === 0 ? `callb ${f[kind]} 2` : `calle ${f.script} ${f[kind]} 2`;
|
|
737
|
+
return ["push1", `pushi ${flag}`, call];
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** The condition's value in acc (non-zero: holds). */
|
|
741
|
+
private expr(e: YarnExpr, where: string): string[] {
|
|
742
|
+
switch (e.kind) {
|
|
743
|
+
case "const":
|
|
744
|
+
return [`ldi ${e.value ? 1 : 0}`];
|
|
745
|
+
case "var":
|
|
746
|
+
return this.flagCall("test", this.flag(e.name, where));
|
|
747
|
+
case "not":
|
|
748
|
+
return [...this.expr(e.of, where), "not"];
|
|
749
|
+
case "and":
|
|
750
|
+
case "or": {
|
|
751
|
+
const done = this.label();
|
|
752
|
+
return [...this.expr(e.a, where), `${e.kind === "and" ? "bnt" : "bt"} ${done}`, ...this.expr(e.b, where), `${done}:`];
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
private setFlag(name: string, value: YarnExpr, where: string): string[] {
|
|
758
|
+
const flag = this.flag(name, where);
|
|
759
|
+
if (value.kind === "const") return this.flagCall(value.value ? "set" : "clear", flag);
|
|
760
|
+
const clear = this.label(), done = this.label();
|
|
761
|
+
return [...this.expr(value, where), `bnt ${clear}`, ...this.flagCall("set", flag), `jmp ${done}`, `${clear}:`, ...this.flagCall("clear", flag), `${done}:`];
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// --- Objects ---
|
|
765
|
+
|
|
766
|
+
private message(noun: number, verb: number, cond: number, seq: number, l: YarnLine, label: string): void {
|
|
767
|
+
const where = `${this.yarnFile}:${l.line}`;
|
|
768
|
+
this.messages.push({ noun, verb, cond, seq, talker: this.talkerOf(l.speaker, where), text: plainText(l.text, where), label });
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
private talkerOf(speaker: string | undefined, where: string): number {
|
|
772
|
+
if (speaker === undefined) return this.target.narrator;
|
|
773
|
+
const s = speaker.toLowerCase();
|
|
774
|
+
if (s === "narrator") return this.target.narrator;
|
|
775
|
+
if (s === "hero") return this.target.heroTalker;
|
|
776
|
+
const t = this.talkers.get(s);
|
|
777
|
+
if (t !== undefined) return t;
|
|
778
|
+
const known = ["Narrator", "Hero", ...Object.keys(this.spec.characters ?? {})];
|
|
779
|
+
throw new ContentError(`${where}: unknown speaker "${speaker}" (add them under characters: in the room; known: ${known.join(", ")})`);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
private instance(label: string, cls: string, props: Record<string, number | string>, methods: string[] = [], shownName = label): void {
|
|
783
|
+
const overrides = this.spec.properties?.[label === this.name ? "room" : label] ?? {};
|
|
784
|
+
const all: Record<string, number | string> = { classScript: 0, info: 0, name: `@s_${label}`, ...props, ...overrides };
|
|
785
|
+
this.instances.push(
|
|
786
|
+
`instance ${label} of ${cls}`,
|
|
787
|
+
...Object.entries(all).map(([k, v]) => ` ${k} ${typeof v === "number" ? num(v) : v}`),
|
|
788
|
+
...methods.map((m) => ` method ${m} ${label}::${m}`),
|
|
789
|
+
"",
|
|
790
|
+
);
|
|
791
|
+
this.strings.push(` s_${label} "${shownName.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
private roomInstance(hasEnterScript: boolean, hasCharacters: boolean): void {
|
|
795
|
+
const { spec, target } = this;
|
|
796
|
+
const verbs = this.scriptedVerbs.get("room") ?? [];
|
|
797
|
+
this.instance(
|
|
798
|
+
this.name, target.roomClass, { noun: 1, modNum: -1, picture: spec.picture ?? spec.room, ...target.roomDefaults },
|
|
799
|
+
["init", ...(hasCharacters ? ["findTalker"] : []), ...(verbs.length ? ["doVerb"] : [])],
|
|
800
|
+
);
|
|
801
|
+
if (hasCharacters) this.findTalker();
|
|
802
|
+
if (verbs.length) this.doVerb(this.name, target.roomClass, verbs);
|
|
803
|
+
const c: string[] = [`${this.name}::init:`];
|
|
804
|
+
const hero = spec.hero;
|
|
805
|
+
const egoCalls: [string, Val[]][] = [["init", []]];
|
|
806
|
+
if (hero?.at) egoCalls.push(["x", [hero.at[0]]], ["y", [hero.at[1]]]);
|
|
807
|
+
egoCalls.push(["normalize", []]);
|
|
808
|
+
if (hero?.scale) egoCalls.push(["setScaler", [{ cls: "Scaler" }, hero.scale.front, hero.scale.back, hero.scale.frontY, hero.scale.backY]]);
|
|
809
|
+
c.push(...send({ global: target.globals.ego }, egoCalls));
|
|
810
|
+
if (spec.music !== undefined) c.push(...this.playMusic(spec.music));
|
|
811
|
+
for (const name of [...Object.keys(spec.features ?? {}), ...Object.keys(spec.exits ?? {})]) c.push(...send({ obj: name }, [["init", []]]));
|
|
812
|
+
for (const [name, p] of Object.entries(spec.props ?? {})) {
|
|
813
|
+
c.push(...send({ obj: name }, [["init", []], ...(p.cycle === "forward" ? [["setCycle", [{ cls: this.target.forwardCycle }]] as [string, Val[]]] : [])]));
|
|
814
|
+
}
|
|
815
|
+
const polygons: [number, [number, number][]][] = [
|
|
816
|
+
...(spec.walkable ? [[3, spec.walkable] as [number, [number, number][]]] : []),
|
|
817
|
+
...(spec.obstacles ?? []).map((p) => [2, p] as [number, [number, number][]]),
|
|
818
|
+
];
|
|
819
|
+
for (const [type, points] of polygons) {
|
|
820
|
+
// curRoom addObstacle: ((Polygon new) type: t, init: points..., yourself): the
|
|
821
|
+
// type/init/yourself words are pushed first, then `Polygon new` leaves the receiver
|
|
822
|
+
// in acc for the outer send.
|
|
823
|
+
const poly = send({ cls: "Polygon" }, [["new", []]]);
|
|
824
|
+
const inner: string[] = [];
|
|
825
|
+
let words = 0;
|
|
826
|
+
for (const [sel, args] of [["type", [type]], ["init", points.flat()], ["yourself", []]] as [string, number[]][]) {
|
|
827
|
+
inner.push(`pushi #${sel}`, ...pushVal(args.length), ...args.flatMap((a) => pushVal(a)));
|
|
828
|
+
words += 2 + args.length;
|
|
829
|
+
}
|
|
830
|
+
c.push(`pushi #addObstacle`, "push1", ...inner, ...poly, `send ${words * 2}`, "push", `lag ${target.globals.curRoom}`, "send 6");
|
|
831
|
+
}
|
|
832
|
+
for (const conv of this.conversations) {
|
|
833
|
+
c.push(...send({ obj: `${conv.thing}Teller` }, [["init", [{ obj: conv.thing }, spec.room, conv.sayNoun, target.teller.verb, conv.rootNoun]]]));
|
|
834
|
+
}
|
|
835
|
+
c.push("pushi #init", "push0", "&rest 1", `super ${target.roomClass} 4`);
|
|
836
|
+
if (hasEnterScript) c.push(...send("self", [["setScript", [{ obj: "sEnter" }]]]));
|
|
837
|
+
c.push("ret", "");
|
|
838
|
+
this.code.push(...c);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/** doVerb: given verbs run their script (in the room); anything else as the class does. */
|
|
842
|
+
private doVerb(label: string, superClass: string, cases: [verb: number, script: string][]): void {
|
|
843
|
+
const done = this.label();
|
|
844
|
+
const c = [`${label}::doVerb:`, "lsp 1"];
|
|
845
|
+
for (const [verb, script] of cases) {
|
|
846
|
+
const next = this.label();
|
|
847
|
+
c.push("dup", `ldi ${verb}`, "eq?", `bnt ${next}`, ...send({ global: this.target.globals.curRoom }, [["setScript", [{ obj: script }]]]), `jmp ${done}`, `${next}:`);
|
|
848
|
+
}
|
|
849
|
+
c.push("pushi #doVerb", "push1", "lsp 1", "&rest 2", `super ${superClass} 6`, `${done}:`, "toss", "ret", "");
|
|
850
|
+
this.code.push(...c);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** A character's talker: a plain text box, or a portrait talker with its parts. */
|
|
854
|
+
private character(key: string, c: CharacterSpec): void {
|
|
855
|
+
const { target } = this;
|
|
856
|
+
const shown = c.name ?? cap(key);
|
|
857
|
+
if (c.portrait === undefined) {
|
|
858
|
+
this.instance(`${key}Talker`, target.voice.class, target.voice.props, [], shown);
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
const p = target.portrait;
|
|
862
|
+
const [x, y] = p.at;
|
|
863
|
+
this.instance(`${key}Talker`, p.class, p.props, ["init"], shown);
|
|
864
|
+
this.instance(`${key}Bust`, "View", { x, y, view: c.portrait, loop: 0, cel: 0, signal: p.frameSignal });
|
|
865
|
+
this.instance(`${key}Mouth`, "Prop", { x, y, view: c.portrait, loop: 1, cel: 0, signal: p.partSignal });
|
|
866
|
+
this.instance(`${key}Eyes`, "Prop", { x, y, view: c.portrait, loop: 2, cel: 0, signal: p.partSignal });
|
|
867
|
+
// init: mouth bust eyes frame (the bust is drawn as the frame, as the game's own do).
|
|
868
|
+
// Every part gets the talker's priority; the game's portraits sort mouth and eyes above
|
|
869
|
+
// the bust by their y, but ours share one position, so lift them a priority above it.
|
|
870
|
+
this.code.push(
|
|
871
|
+
`${key}Talker::init:`,
|
|
872
|
+
...[
|
|
873
|
+
"pushi #init", "pushi 4", `lofsa @${key}Mouth`, "push", "push0", `lofsa @${key}Eyes`, "push", `lofsa @${key}Bust`, "push",
|
|
874
|
+
"&rest 1", `super ${p.class} 12`,
|
|
875
|
+
"pushi #setPri", "push1", "pTos priority", "ldi 1", "add", "push", `lofsa @${key}Mouth`, "send 6",
|
|
876
|
+
"pushi #setPri", "push1", "pTos priority", "ldi 1", "add", "push", `lofsa @${key}Eyes`, "send 6",
|
|
877
|
+
"ret",
|
|
878
|
+
],
|
|
879
|
+
"",
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/** A conversation's Teller, with showCases (conditional topics) and sayMessage (scripted answers). */
|
|
884
|
+
private teller(conv: Conversation): void {
|
|
885
|
+
const { target } = this;
|
|
886
|
+
const label = `${conv.thing}Teller`;
|
|
887
|
+
const methods = [...(conv.conditional.length ? ["showCases"] : []), ...(conv.scripted.length ? ["sayMessage"] : [])];
|
|
888
|
+
this.instance(label, target.teller.class, { ...target.teller.props, actionVerb: conv.opensWith }, methods);
|
|
889
|
+
if (conv.conditional.length) {
|
|
890
|
+
// super showCases: topic offered? ... (pairs; a topic whose value is 0 is left out)
|
|
891
|
+
const c = [`${label}::showCases:`, "pushi #showCases", `pushi ${conv.conditional.length * 2}`];
|
|
892
|
+
for (const { topic, when, where } of conv.conditional) c.push(`pushi ${topic}`, ...this.expr(when, where), "push");
|
|
893
|
+
c.push(`super ${target.teller.class} ${(2 + conv.conditional.length * 2) * 2}`, "ret", "");
|
|
894
|
+
this.code.push(...c);
|
|
895
|
+
}
|
|
896
|
+
if (conv.scripted.length) {
|
|
897
|
+
const c = [`${label}::sayMessage:`, "pTos iconValue"];
|
|
898
|
+
const other = this.label();
|
|
899
|
+
for (const { topic, script } of conv.scripted) {
|
|
900
|
+
const next = this.label();
|
|
901
|
+
c.push(
|
|
902
|
+
"dup", `ldi ${topic}`, "eq?", `bnt ${next}`, "toss",
|
|
903
|
+
...send({ global: target.globals.curRoom }, [["setScript", [{ obj: script }, "self"]]]), "ret", `${next}:`,
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
c.push(`${other}:`, "toss", "pushi #sayMessage", "push0", "&rest 1", `super ${target.teller.class} 4`, "ret", "");
|
|
907
|
+
this.code.push(...c);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/** findTalker: n -> this room's character for talker number n (the game asks for 200+). */
|
|
912
|
+
private findTalker(): void {
|
|
913
|
+
const c = [`${this.name}::findTalker:`, "lsp 1"];
|
|
914
|
+
const done = ".findTalkerDone";
|
|
915
|
+
Object.keys(this.spec.characters ?? {}).forEach((key, i) => {
|
|
916
|
+
const next = `.findTalker${i + 1}`;
|
|
917
|
+
c.push("dup", `ldi ${this.target.firstRoomTalker + i}`, "eq?", `bnt ${next}`, `lofsa @${key}Talker`, `jmp ${done}`, `${next}:`);
|
|
918
|
+
});
|
|
919
|
+
// Not one of ours: the narrator, as the game does for numbers it doesn't know.
|
|
920
|
+
c.push(`lag ${this.target.globals.narratorObject}`, `${done}:`, "toss", "ret", "");
|
|
921
|
+
this.code.push(...c);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
private feature(name: string, f: FeatureSpec, exitScript?: string): void {
|
|
925
|
+
const [l, t, r, b] = f.rect;
|
|
926
|
+
const [x, y] = f.at ?? [Math.round((l + r) / 2), Math.round((t + b) / 2)];
|
|
927
|
+
// Do on an exit leaves (its script); verbs with scripted Yarn run theirs.
|
|
928
|
+
const cases: [number, string][] = [...(exitScript ? [[this.target.verbs.do!, exitScript] as [number, string]] : []), ...(this.scriptedVerbs.get(name) ?? [])];
|
|
929
|
+
this.instance(
|
|
930
|
+
name,
|
|
931
|
+
"Feature",
|
|
932
|
+
{
|
|
933
|
+
noun: this.nouns.get(name)!, modNum: -1, nsLeft: l, nsTop: t, nsRight: r, nsBottom: b, sightAngle: f.sightAngle ?? 180,
|
|
934
|
+
...(f.approach ? { approachX: f.approach[0], approachY: f.approach[1] } : {}), x, y,
|
|
935
|
+
},
|
|
936
|
+
cases.length ? ["doVerb"] : [],
|
|
937
|
+
);
|
|
938
|
+
if (cases.length) this.doVerb(name, "Feature", cases);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
private prop(name: string, p: PropSpec): void {
|
|
942
|
+
const cases = this.scriptedVerbs.get(name) ?? [];
|
|
943
|
+
this.instance(
|
|
944
|
+
name, p.moves ? "Actor" : "Prop",
|
|
945
|
+
{ 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 },
|
|
946
|
+
cases.length ? ["doVerb"] : [],
|
|
947
|
+
);
|
|
948
|
+
if (cases.length) this.doVerb(name, p.moves ? "Actor" : "Prop", cases);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/** A Script instance whose changeState runs `states` in order. */
|
|
952
|
+
private script(name: string, states: State[]): void {
|
|
953
|
+
this.instance(name, "Script", {}, ["changeState"]);
|
|
954
|
+
const c = [`${name}::changeState:`, "lap 1", "aTop state", "push"];
|
|
955
|
+
const done = `.${name}_done`;
|
|
956
|
+
states.forEach((body, i) => {
|
|
957
|
+
const next = i + 1 < states.length ? `.${name}_${i + 1}` : done;
|
|
958
|
+
if (i > 0) c.push(`.${name}_${i}:`);
|
|
959
|
+
c.push("dup", `ldi ${i}`, "eq?", `bnt ${next}`, ...body);
|
|
960
|
+
if (i + 1 < states.length) c.push(`jmp ${done}`);
|
|
961
|
+
});
|
|
962
|
+
c.push(`${done}:`, "toss", "ret", "");
|
|
963
|
+
this.code.push(...c);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
private assembly(): string {
|
|
967
|
+
const nounList = [...this.nouns].map(([n, v]) => `${v} ${n}`).join(", ");
|
|
968
|
+
return [
|
|
969
|
+
`; Room ${this.spec.room}, compiled from its .room.yaml and .yarn by @sci-ts/content. Don't edit:`,
|
|
970
|
+
`; change the sources and rebuild. Nouns: ${nounList}.`,
|
|
971
|
+
`script ${this.spec.room}`,
|
|
972
|
+
"",
|
|
973
|
+
"exports",
|
|
974
|
+
` ${this.name}`,
|
|
975
|
+
"",
|
|
976
|
+
...this.instances,
|
|
977
|
+
"strings",
|
|
978
|
+
...this.strings,
|
|
979
|
+
"",
|
|
980
|
+
"code",
|
|
981
|
+
"",
|
|
982
|
+
...this.code.map((l) => (l.endsWith(":") || l === "" ? l : ` ${l}`)),
|
|
983
|
+
].join("\n");
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
private messageText(): string {
|
|
987
|
+
const lines = [`messages ${this.spec.room} version ${this.target.messageVersion}`, '; noun verb cond seq talker "text" (compiled from Yarn)'];
|
|
988
|
+
for (const m of this.messages) {
|
|
989
|
+
lines.push(`${m.noun} ${m.verb} ${m.cond} ${m.seq} ${m.talker} "${m.text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}" ; ${m.label}`);
|
|
990
|
+
}
|
|
991
|
+
return `${lines.join("\n")}\n`;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
const cap = (s: string) => s[0]!.toUpperCase() + s.slice(1);
|
|
996
|
+
const counter = (from: number) => {
|
|
997
|
+
let n = from;
|
|
998
|
+
return () => n++;
|
|
999
|
+
};
|
|
1000
|
+
|
|
1001
|
+
/** Message text is single bytes: typographic punctuation becomes ASCII, anything else is an error. */
|
|
1002
|
+
function plainText(text: string, where: string): string {
|
|
1003
|
+
const out = text
|
|
1004
|
+
.replace(/[‘’]/g, "'")
|
|
1005
|
+
.replace(/[“”]/g, '"')
|
|
1006
|
+
.replace(/[–—]/g, "--")
|
|
1007
|
+
.replace(/…/g, "...");
|
|
1008
|
+
const bad = /[^\x20-\x7e]/.exec(out);
|
|
1009
|
+
if (bad) throw new ContentError(`${where}: "${bad[0]}" can't be shown by the game's fonts (plain ASCII only for now)`);
|
|
1010
|
+
return out;
|
|
1011
|
+
}
|