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,177 @@
|
|
|
1
|
+
import { activeMods, availableMods, openGame, rememberMods } from "./files.ts";
|
|
2
|
+
import { GameSession } from "./session.ts";
|
|
3
|
+
import { ResourceType, gameGlobal, heroInPlay, requestRoom, saves, savesToJson, type MemorySaveStore } from "../../../packages/sci/src/index.ts";
|
|
4
|
+
import { APP_ID } from "./app.ts";
|
|
5
|
+
import { gameProfile } from "./game-profile.ts";
|
|
6
|
+
|
|
7
|
+
const canvas = document.getElementById("screen") as HTMLCanvasElement;
|
|
8
|
+
const status = document.getElementById("status")!;
|
|
9
|
+
const errorBox = document.getElementById("error")!;
|
|
10
|
+
const musicButton = document.getElementById("music") as HTMLButtonElement;
|
|
11
|
+
|
|
12
|
+
// Mods (?mods=a,b or the last choice) load as patch directories over the game.
|
|
13
|
+
const { rm, mods } = await openGame();
|
|
14
|
+
// `?mute` keeps audio off (e.g. automated testing in a background tab).
|
|
15
|
+
const params = new URLSearchParams(location.search);
|
|
16
|
+
// `?debug` turns on Sierra's own debug room (Alt-T teleport, Alt-G flags, Alt-I items...).
|
|
17
|
+
const session = await GameSession.create({ canvas, rm, mods, muted: params.has("mute"), debug: params.has("debug") });
|
|
18
|
+
await setUpModPicker();
|
|
19
|
+
// For poking at the running game from the browser console (and automated checks).
|
|
20
|
+
(window as unknown as { sci: unknown }).sci = { session };
|
|
21
|
+
|
|
22
|
+
// Saved games as a JSON file, to replay them headlessly (tools/explore.ts loadsaves:<file>).
|
|
23
|
+
document.getElementById("export-saves")!.addEventListener("click", () => {
|
|
24
|
+
const store = saves(session.vm).store as MemorySaveStore;
|
|
25
|
+
const a = document.createElement("a");
|
|
26
|
+
a.href = URL.createObjectURL(new Blob([savesToJson([...store.saves.values()])], { type: "application/json" }));
|
|
27
|
+
a.download = `${APP_ID}-saves${mods.length ? `-${mods.join("+")}` : ""}.json`;
|
|
28
|
+
a.click();
|
|
29
|
+
URL.revokeObjectURL(a.href);
|
|
30
|
+
});
|
|
31
|
+
const startRoom = /^#room\/(\d+)$/.exec(location.hash) ? Number(location.hash.slice(6)) : undefined;
|
|
32
|
+
/** What room navigation is doing, shown on the status line. */
|
|
33
|
+
let roomStatus: string | undefined;
|
|
34
|
+
setUpRoomNavigation();
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Rooms in the address bar: `#room/250` goes there. Opened that way, the game doesn't make
|
|
38
|
+
* you sit through the intro: it loads your newest saved game (your hero, your progress), or
|
|
39
|
+
* with none (or `?fresh`), fast-forwards through character creation to a new Wizard called
|
|
40
|
+
* Hero. Then it jumps. Editing the hash jumps again, and Back returns to the last jump; the
|
|
41
|
+
* hash follows the game's own room changes without adding history entries.
|
|
42
|
+
*/
|
|
43
|
+
function setUpRoomNavigation() {
|
|
44
|
+
/** The room asked for, kept until the game gets there (or gives up). */
|
|
45
|
+
let wanted: { room: number; since: number } | undefined;
|
|
46
|
+
let shown = 0;
|
|
47
|
+
const readHash = () => {
|
|
48
|
+
const m = /^#room\/(\d+)$/.exec(location.hash);
|
|
49
|
+
if (!m) return;
|
|
50
|
+
const room = Number(m[1]);
|
|
51
|
+
if (room === shown && !wanted) return; // the hash we wrote ourselves
|
|
52
|
+
if (!rm.has({ type: ResourceType.Script, number: room })) {
|
|
53
|
+
roomStatus = `There's no room ${room}`;
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
wanted = { room, since: performance.now() };
|
|
57
|
+
};
|
|
58
|
+
readHash();
|
|
59
|
+
// Editing the address bar fires hashchange (and popstate, in some browsers and for Back).
|
|
60
|
+
addEventListener("hashchange", readHash);
|
|
61
|
+
addEventListener("popstate", readHash);
|
|
62
|
+
|
|
63
|
+
session.onDraw.push(() => {
|
|
64
|
+
const vm = session.vm;
|
|
65
|
+
// The 20 s allowance only counts while a hero is in play (making one takes a while).
|
|
66
|
+
if (session.fastForwarding || !heroInPlay(vm)) {
|
|
67
|
+
if (wanted) wanted.since = performance.now();
|
|
68
|
+
if (session.fastForwarding) return;
|
|
69
|
+
}
|
|
70
|
+
const room = gameGlobal(vm, "curRoomNum") ?? 0;
|
|
71
|
+
if (wanted) {
|
|
72
|
+
if (room === wanted.room) {
|
|
73
|
+
roomStatus = undefined;
|
|
74
|
+
wanted = undefined;
|
|
75
|
+
} else if (!heroInPlay(vm)) {
|
|
76
|
+
roomStatus = `Room ${wanted.room}: waiting for a hero (create one, or restore a save)`;
|
|
77
|
+
} else if (performance.now() - wanted.since > 20_000) {
|
|
78
|
+
roomStatus = `Couldn't get to room ${wanted.room} (the game didn't leave room ${room})`;
|
|
79
|
+
wanted = undefined;
|
|
80
|
+
} else {
|
|
81
|
+
// Ask again every frame: while a message or a scripted scene holds the game, its
|
|
82
|
+
// main loop isn't checking, and a room may reset the request when it starts.
|
|
83
|
+
requestRoom(vm, wanted.room);
|
|
84
|
+
roomStatus = performance.now() - wanted.since > 1500
|
|
85
|
+
? `Going to room ${wanted.room}… (close any message on screen)`
|
|
86
|
+
: `Going to room ${wanted.room}…`;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (room && room !== shown && heroInPlay(vm)) {
|
|
90
|
+
shown = room;
|
|
91
|
+
if (location.hash !== `#room/${room}`) history.replaceState(null, "", `#room/${room}`);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** How a `#room/N` visit gets a hero: the newest save, or a quick new one. */
|
|
97
|
+
const quickStart = gameProfile(session.vm)?.quickStart(session.vm);
|
|
98
|
+
|
|
99
|
+
function startGame() {
|
|
100
|
+
const store = saves(session.vm).store;
|
|
101
|
+
const info = params.has("fresh") ? undefined : store.list()[0];
|
|
102
|
+
const newest = info && store.get(info.id);
|
|
103
|
+
if (startRoom === undefined) return session.start();
|
|
104
|
+
if (newest) {
|
|
105
|
+
status.textContent = `Loading "${newest.info.description}" to go to room ${startRoom}…`;
|
|
106
|
+
session.start();
|
|
107
|
+
// Restore once the game is up (it needs its first frame to exist), like the game's own Restore.
|
|
108
|
+
let done = false;
|
|
109
|
+
session.onDraw.push(() => {
|
|
110
|
+
if (done) return;
|
|
111
|
+
done = true;
|
|
112
|
+
session.restore(newest.snapshot);
|
|
113
|
+
});
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (!quickStart) {
|
|
117
|
+
status.textContent = `Play until your character can walk around, then it goes to room ${startRoom}`;
|
|
118
|
+
return session.start();
|
|
119
|
+
}
|
|
120
|
+
status.textContent = `Creating a hero to go to room ${startRoom}…`;
|
|
121
|
+
session.start({ fastForward: quickStart });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Checkboxes for the built mods; applying reloads the page with the new set. */
|
|
125
|
+
async function setUpModPicker() {
|
|
126
|
+
const list = document.getElementById("mod-list")!;
|
|
127
|
+
const apply = document.getElementById("mods-apply") as HTMLButtonElement;
|
|
128
|
+
const summary = document.querySelector("#mods summary")!;
|
|
129
|
+
const built = await availableMods();
|
|
130
|
+
const on = new Set(activeMods());
|
|
131
|
+
summary.textContent = on.size ? `Mods: ${[...on].join(", ")}` : "Mods";
|
|
132
|
+
if (!built.length) {
|
|
133
|
+
list.innerHTML = '<span class="none">No mods built. Try <code>pnpm mod build mods/altar-hello</code>.</span>';
|
|
134
|
+
apply.hidden = true;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
for (const name of built) {
|
|
138
|
+
const box = Object.assign(document.createElement("input"), { type: "checkbox", checked: on.has(name), value: name });
|
|
139
|
+
const label = document.createElement("label");
|
|
140
|
+
label.append(box, ` ${name}`);
|
|
141
|
+
list.append(label);
|
|
142
|
+
}
|
|
143
|
+
apply.addEventListener("click", () => {
|
|
144
|
+
const chosen = [...list.querySelectorAll<HTMLInputElement>("input:checked")].map((b) => b.value);
|
|
145
|
+
rememberMods(chosen);
|
|
146
|
+
const url = new URL(location.href);
|
|
147
|
+
url.searchParams.set("mods", chosen.join(","));
|
|
148
|
+
location.href = url.toString();
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const showMusicMode = () =>
|
|
153
|
+
(musicButton.textContent = session.musicMode === "adlib" ? "Music: AdLib (OPL2 FM, 1994 Sound Blaster)" : "Music: General MIDI (SoundFont)");
|
|
154
|
+
musicButton.addEventListener("click", () => {
|
|
155
|
+
if (session.hasAdLib) session.setMusicMode(session.musicMode === "adlib" ? "gm" : "adlib");
|
|
156
|
+
showMusicMode();
|
|
157
|
+
});
|
|
158
|
+
showMusicMode();
|
|
159
|
+
|
|
160
|
+
let lastStatus = 0;
|
|
161
|
+
session.onDraw.push(() => {
|
|
162
|
+
if (session.error) {
|
|
163
|
+
errorBox.hidden = false;
|
|
164
|
+
errorBox.textContent = `${session.error.message}\n${session.error.backtrace.map((l) => ` at ${l}`).join("\n")}`;
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const now = performance.now();
|
|
168
|
+
if (session.fastForwarding) {
|
|
169
|
+
if (now - lastStatus > 250) (status.textContent = `Creating a hero… (${Math.min(100, Math.round((session.frames / (quickStart?.until ?? 1)) * 100))}%)`), (lastStatus = now);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (now - lastStatus < 1000) return;
|
|
173
|
+
lastStatus = now;
|
|
174
|
+
status.textContent = !session.vm.running ? "Game exited." : roomStatus ?? `${session.fps} fps · ${session.vm.instructions.toLocaleString()} instructions${mods.length ? ` · mods: ${mods.join(", ")}` : ""}${params.has("debug") ? " · debug: Alt-T teleport" : ""}${session.musicMode === "gm" && !session.music.ready ? " · SoundFont loading" : ""}`;
|
|
175
|
+
});
|
|
176
|
+
status.textContent = "Running";
|
|
177
|
+
startGame();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { MemorySaveStore } from "../../../packages/sci/src/index.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Saved games in IndexedDB (per browser, survive reloads). Kernels need synchronous access,
|
|
4
|
+
* so everything is loaded into a MemorySaveStore at startup and writes go through to disk.
|
|
5
|
+
*
|
|
6
|
+
* A save is a snapshot of the running scripts, so it only fits the scripts it was made with:
|
|
7
|
+
* each set of mods gets its own saves (`namespace`, e.g. "altar-hello"). The unmodded game
|
|
8
|
+
* keeps plain numeric keys, as before namespaces existed.
|
|
9
|
+
*/
|
|
10
|
+
export declare function browserSaveStore(namespace?: string): Promise<MemorySaveStore>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { APP_ID } from "./app.js";
|
|
2
|
+
import { MemorySaveStore } from "../../../packages/sci/src/index.js";
|
|
3
|
+
const DB = APP_ID;
|
|
4
|
+
const STORE = "saves";
|
|
5
|
+
function open() {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
const req = indexedDB.open(DB, 1);
|
|
8
|
+
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
|
|
9
|
+
req.onsuccess = () => resolve(req.result);
|
|
10
|
+
req.onerror = () => reject(req.error);
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Saved games in IndexedDB (per browser, survive reloads). Kernels need synchronous access,
|
|
15
|
+
* so everything is loaded into a MemorySaveStore at startup and writes go through to disk.
|
|
16
|
+
*
|
|
17
|
+
* A save is a snapshot of the running scripts, so it only fits the scripts it was made with:
|
|
18
|
+
* each set of mods gets its own saves (`namespace`, e.g. "altar-hello"). The unmodded game
|
|
19
|
+
* keeps plain numeric keys, as before namespaces existed.
|
|
20
|
+
*/
|
|
21
|
+
export async function browserSaveStore(namespace = "") {
|
|
22
|
+
const store = new MemorySaveStore();
|
|
23
|
+
const key = (id) => (namespace ? `${namespace}:${id}` : id);
|
|
24
|
+
const mine = (k) => (namespace ? typeof k === "string" && k.startsWith(`${namespace}:`) : typeof k === "number");
|
|
25
|
+
let db;
|
|
26
|
+
try {
|
|
27
|
+
db = await open();
|
|
28
|
+
await new Promise((resolve, reject) => {
|
|
29
|
+
const req = db.transaction(STORE).objectStore(STORE).openCursor();
|
|
30
|
+
req.onsuccess = () => {
|
|
31
|
+
const cursor = req.result;
|
|
32
|
+
if (!cursor)
|
|
33
|
+
return resolve();
|
|
34
|
+
if (mine(cursor.key)) {
|
|
35
|
+
const save = cursor.value;
|
|
36
|
+
store.saves.set(save.info.id, save);
|
|
37
|
+
}
|
|
38
|
+
cursor.continue();
|
|
39
|
+
};
|
|
40
|
+
req.onerror = () => reject(req.error);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
console.warn("Saved games unavailable (IndexedDB blocked?); saves will last until reload.", e);
|
|
45
|
+
}
|
|
46
|
+
store.onWrite = (info, snapshot) => {
|
|
47
|
+
db?.transaction(STORE, "readwrite").objectStore(STORE).put({ info, snapshot }, key(info.id));
|
|
48
|
+
};
|
|
49
|
+
return store;
|
|
50
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { APP_ID } from "./app.ts";
|
|
2
|
+
import { MemorySaveStore, type SaveInfo, type VmSnapshot } from "../../../packages/sci/src/index.ts";
|
|
3
|
+
|
|
4
|
+
const DB = APP_ID;
|
|
5
|
+
const STORE = "saves";
|
|
6
|
+
|
|
7
|
+
function open(): Promise<IDBDatabase> {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
const req = indexedDB.open(DB, 1);
|
|
10
|
+
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
|
|
11
|
+
req.onsuccess = () => resolve(req.result);
|
|
12
|
+
req.onerror = () => reject(req.error);
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Saved games in IndexedDB (per browser, survive reloads). Kernels need synchronous access,
|
|
18
|
+
* so everything is loaded into a MemorySaveStore at startup and writes go through to disk.
|
|
19
|
+
*
|
|
20
|
+
* A save is a snapshot of the running scripts, so it only fits the scripts it was made with:
|
|
21
|
+
* each set of mods gets its own saves (`namespace`, e.g. "altar-hello"). The unmodded game
|
|
22
|
+
* keeps plain numeric keys, as before namespaces existed.
|
|
23
|
+
*/
|
|
24
|
+
export async function browserSaveStore(namespace = ""): Promise<MemorySaveStore> {
|
|
25
|
+
const store = new MemorySaveStore();
|
|
26
|
+
const key = (id: number): IDBValidKey => (namespace ? `${namespace}:${id}` : id);
|
|
27
|
+
const mine = (k: IDBValidKey) => (namespace ? typeof k === "string" && k.startsWith(`${namespace}:`) : typeof k === "number");
|
|
28
|
+
let db: IDBDatabase | undefined;
|
|
29
|
+
try {
|
|
30
|
+
db = await open();
|
|
31
|
+
await new Promise<void>((resolve, reject) => {
|
|
32
|
+
const req = db!.transaction(STORE).objectStore(STORE).openCursor();
|
|
33
|
+
req.onsuccess = () => {
|
|
34
|
+
const cursor = req.result;
|
|
35
|
+
if (!cursor) return resolve();
|
|
36
|
+
if (mine(cursor.key)) {
|
|
37
|
+
const save = cursor.value as { info: SaveInfo; snapshot: VmSnapshot };
|
|
38
|
+
store.saves.set(save.info.id, save);
|
|
39
|
+
}
|
|
40
|
+
cursor.continue();
|
|
41
|
+
};
|
|
42
|
+
req.onerror = () => reject(req.error);
|
|
43
|
+
});
|
|
44
|
+
} catch (e) {
|
|
45
|
+
console.warn("Saved games unavailable (IndexedDB blocked?); saves will last until reload.", e);
|
|
46
|
+
}
|
|
47
|
+
store.onWrite = (info, snapshot) => {
|
|
48
|
+
db?.transaction(STORE, "readwrite").objectStore(STORE).put({ info, snapshot }, key(info.id));
|
|
49
|
+
};
|
|
50
|
+
return store;
|
|
51
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { ResourceManager, Vm, type VmSnapshot } from "../../../packages/sci/src/index.ts";
|
|
2
|
+
import { WebAudioOutput } from "./audio.ts";
|
|
3
|
+
import { SoundFontMidi } from "./music.ts";
|
|
4
|
+
export type MusicMode = "gm" | "adlib";
|
|
5
|
+
export interface SessionOptions {
|
|
6
|
+
canvas: HTMLCanvasElement;
|
|
7
|
+
/** Reuse an open resource manager (it gets preloaded). */
|
|
8
|
+
rm?: ResourceManager;
|
|
9
|
+
/** Active mods: saves are kept apart per set of mods. */
|
|
10
|
+
mods?: string[];
|
|
11
|
+
/** Keep saves under this name instead (the editor's, apart from playing). */
|
|
12
|
+
saveNamespace?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Sierra's debug room, which the game loads when a file "18.scr" exists (their machines had one).
|
|
15
|
+
* Alt-T teleports, Alt-G sets flags, Alt-I gives items, Alt-H sets the hour... (Alt-? lists).
|
|
16
|
+
*/
|
|
17
|
+
debug?: boolean;
|
|
18
|
+
/** Start with audio suspended; `setMuted(false)` turns it on. */
|
|
19
|
+
muted?: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Keep running when the page isn't painted (a hidden tab or pane), where
|
|
22
|
+
* requestAnimationFrame stops: a timer takes over. For automated testing.
|
|
23
|
+
*/
|
|
24
|
+
runHidden?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Called for canvas mouse-downs before the game sees them; return true to swallow the
|
|
27
|
+
* click (the explorer's inspect mode). Coordinates are game screen pixels.
|
|
28
|
+
*/
|
|
29
|
+
interceptClick?: (x: number, y: number, e: MouseEvent) => boolean;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* One running game in a canvas: VM, audio, input and the frame loop. The player page and
|
|
33
|
+
* the explorer's live view both run the game through this.
|
|
34
|
+
*/
|
|
35
|
+
export declare class GameSession {
|
|
36
|
+
readonly rm: ResourceManager;
|
|
37
|
+
readonly vm: Vm;
|
|
38
|
+
readonly canvas: HTMLCanvasElement;
|
|
39
|
+
readonly sound: WebAudioOutput;
|
|
40
|
+
readonly music: SoundFontMidi;
|
|
41
|
+
/** Only for games with an AdLib instrument bank (patch 3). */
|
|
42
|
+
private readonly adlib;
|
|
43
|
+
private readonly options;
|
|
44
|
+
paused: boolean;
|
|
45
|
+
/** Game frames run so far. */
|
|
46
|
+
frames: number;
|
|
47
|
+
fps: number;
|
|
48
|
+
error: {
|
|
49
|
+
message: string;
|
|
50
|
+
backtrace: string[];
|
|
51
|
+
} | undefined;
|
|
52
|
+
/** After each drawn browser frame. */
|
|
53
|
+
readonly onDraw: (() => void)[];
|
|
54
|
+
private latest;
|
|
55
|
+
private readonly ctx;
|
|
56
|
+
private readonly image;
|
|
57
|
+
private musicModeValue;
|
|
58
|
+
private stepsOwed;
|
|
59
|
+
private constructor();
|
|
60
|
+
static create(options: SessionOptions): Promise<GameSession>;
|
|
61
|
+
get musicMode(): MusicMode;
|
|
62
|
+
/** Whether the game can play its AdLib tracks (it has an instrument bank). */
|
|
63
|
+
get hasAdLib(): boolean;
|
|
64
|
+
setMusicMode(mode: MusicMode): void;
|
|
65
|
+
muted: boolean;
|
|
66
|
+
/** Browsers keep audio suspended until a user gesture: unmuted, the next gesture unlocks it. */
|
|
67
|
+
setMuted(muted: boolean): void;
|
|
68
|
+
/** True while `start({ fastForward })` is still running ahead. */
|
|
69
|
+
fastForwarding: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Starts the game and the frame loop. With `fastForward`, the game first runs as fast as
|
|
72
|
+
* it can on a frame clock (60 frames = 1 second of game time), from the very first frame,
|
|
73
|
+
* with `script[n]` called before frame n (scripted input) until frame `until`; audio is
|
|
74
|
+
* silent meanwhile. Then real time takes over, continuing the clock where it was.
|
|
75
|
+
*/
|
|
76
|
+
start(options?: {
|
|
77
|
+
fastForward?: {
|
|
78
|
+
script: Record<number, () => void>;
|
|
79
|
+
until: number;
|
|
80
|
+
done?: () => void;
|
|
81
|
+
};
|
|
82
|
+
}): void;
|
|
83
|
+
/** While paused: run `n` game frames on the next tick. */
|
|
84
|
+
step(n?: number): void;
|
|
85
|
+
snapshot(): VmSnapshot;
|
|
86
|
+
/** Like the game's own restore: replace memory, then restart execution with `replay`. */
|
|
87
|
+
/** Loads a snapshot as the game's own Restore does (see `restoreGame`). */
|
|
88
|
+
restore(snap: VmSnapshot): void;
|
|
89
|
+
private draw;
|
|
90
|
+
private attachInput;
|
|
91
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { EventType, ResourceManager, ResourceType, SciKey, SoundDevice, Vm, allKernels, audio, graphics, hostFiles, input, parseAdLibBank, readingTime, restoreGame, saves, snapshot, } from "../../../packages/sci/src/index.js";
|
|
2
|
+
import { AdLibOutput } from "./adlib.js";
|
|
3
|
+
import { WebAudioOutput } from "./audio.js";
|
|
4
|
+
import { BASE, httpFiles } from "./files.js";
|
|
5
|
+
import { SoundFontMidi } from "./music.js";
|
|
6
|
+
import { APP_ID } from "./app.js";
|
|
7
|
+
import { browserSaveStore } from "./saves.js";
|
|
8
|
+
/**
|
|
9
|
+
* One running game in a canvas: VM, audio, input and the frame loop. The player page and
|
|
10
|
+
* the explorer's live view both run the game through this.
|
|
11
|
+
*/
|
|
12
|
+
export class GameSession {
|
|
13
|
+
rm;
|
|
14
|
+
vm;
|
|
15
|
+
canvas;
|
|
16
|
+
sound;
|
|
17
|
+
music;
|
|
18
|
+
adlib;
|
|
19
|
+
options;
|
|
20
|
+
paused = false;
|
|
21
|
+
/** Game frames run so far. */
|
|
22
|
+
frames = 0;
|
|
23
|
+
fps = 0;
|
|
24
|
+
error;
|
|
25
|
+
/** After each drawn browser frame. */
|
|
26
|
+
onDraw = [];
|
|
27
|
+
latest;
|
|
28
|
+
ctx;
|
|
29
|
+
image;
|
|
30
|
+
musicModeValue = "gm";
|
|
31
|
+
stepsOwed = 0;
|
|
32
|
+
constructor(rm, vm, canvas, sound, music,
|
|
33
|
+
/** Only for games with an AdLib instrument bank (patch 3). */
|
|
34
|
+
adlib, options) {
|
|
35
|
+
this.rm = rm;
|
|
36
|
+
this.vm = vm;
|
|
37
|
+
this.canvas = canvas;
|
|
38
|
+
this.sound = sound;
|
|
39
|
+
this.music = music;
|
|
40
|
+
this.adlib = adlib;
|
|
41
|
+
this.options = options;
|
|
42
|
+
this.ctx = canvas.getContext("2d");
|
|
43
|
+
this.image = this.ctx.createImageData(320, 200);
|
|
44
|
+
}
|
|
45
|
+
static async create(options) {
|
|
46
|
+
const rm = options.rm ?? (await ResourceManager.open(httpFiles));
|
|
47
|
+
await rm.preload();
|
|
48
|
+
const vm = new Vm(rm);
|
|
49
|
+
vm.registerKernels(allKernels);
|
|
50
|
+
// Saved games persist in this browser (IndexedDB) and appear in the game's Restore screen.
|
|
51
|
+
saves(vm).store = await browserSaveStore(options.saveNamespace ?? [...(options.mods ?? [])].sort().join("+"));
|
|
52
|
+
if (options.debug)
|
|
53
|
+
hostFiles(vm).add("18.scr");
|
|
54
|
+
const sound = new WebAudioOutput(httpFiles, (name) => rm.file(name));
|
|
55
|
+
audio(vm).output = sound;
|
|
56
|
+
// Mods add dialogue without recordings: give those lines time to be read.
|
|
57
|
+
if (options.mods?.length)
|
|
58
|
+
audio(vm).missingSpeechTicks = readingTime(vm);
|
|
59
|
+
// Music: General MIDI through a SoundFont (loads in the background), or the AdLib track
|
|
60
|
+
// through Sierra's driver and an OPL2 emulator: what most players heard in 1994.
|
|
61
|
+
const music = new SoundFontMidi(sound.ctx);
|
|
62
|
+
music.load(`${BASE}soundfonts/GeneralUser-GS.sf2`).catch((e) => console.warn("SoundFont unavailable:", e));
|
|
63
|
+
const bank = { type: ResourceType.Patch, number: 3 };
|
|
64
|
+
const adlib = rm.has(bank) ? await AdLibOutput.create(sound.ctx, parseAdLibBank(rm.loadSync(bank).data)) : undefined;
|
|
65
|
+
const session = new GameSession(rm, vm, options.canvas, sound, music, adlib, options);
|
|
66
|
+
graphics(vm).onFrame = (f) => (session.latest = f);
|
|
67
|
+
session.setMusicMode(readMusicMode());
|
|
68
|
+
session.setMuted(!!options.muted);
|
|
69
|
+
session.attachInput();
|
|
70
|
+
for (const type of ["pointerdown", "keydown"])
|
|
71
|
+
window.addEventListener(type, () => session.muted || sound.unlock());
|
|
72
|
+
return session;
|
|
73
|
+
}
|
|
74
|
+
get musicMode() {
|
|
75
|
+
return this.musicModeValue;
|
|
76
|
+
}
|
|
77
|
+
/** Whether the game can play its AdLib tracks (it has an instrument bank). */
|
|
78
|
+
get hasAdLib() {
|
|
79
|
+
return !!this.adlib;
|
|
80
|
+
}
|
|
81
|
+
setMusicMode(mode) {
|
|
82
|
+
if (!this.adlib)
|
|
83
|
+
mode = "gm";
|
|
84
|
+
this.musicModeValue = mode;
|
|
85
|
+
this.adlib?.reset();
|
|
86
|
+
if (mode === "adlib" && this.adlib)
|
|
87
|
+
audio(this.vm).setMusicDevice(SoundDevice.AdLib, this.adlib);
|
|
88
|
+
else
|
|
89
|
+
audio(this.vm).setMusicDevice(SoundDevice.GeneralMidi, this.music);
|
|
90
|
+
try {
|
|
91
|
+
localStorage.setItem(`${APP_ID}.music`, mode);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
/* private mode: just don't remember */
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
muted = false;
|
|
98
|
+
/** Browsers keep audio suspended until a user gesture: unmuted, the next gesture unlocks it. */
|
|
99
|
+
setMuted(muted) {
|
|
100
|
+
this.muted = muted;
|
|
101
|
+
if (muted)
|
|
102
|
+
void this.sound.ctx.suspend();
|
|
103
|
+
else
|
|
104
|
+
this.sound.unlock();
|
|
105
|
+
}
|
|
106
|
+
/** True while `start({ fastForward })` is still running ahead. */
|
|
107
|
+
fastForwarding = false;
|
|
108
|
+
/**
|
|
109
|
+
* Starts the game and the frame loop. With `fastForward`, the game first runs as fast as
|
|
110
|
+
* it can on a frame clock (60 frames = 1 second of game time), from the very first frame,
|
|
111
|
+
* with `script[n]` called before frame n (scripted input) until frame `until`; audio is
|
|
112
|
+
* silent meanwhile. Then real time takes over, continuing the clock where it was.
|
|
113
|
+
*/
|
|
114
|
+
start(options = {}) {
|
|
115
|
+
this.vm.start(this.vm.exportAddress(0, 0), "play");
|
|
116
|
+
const ff = options.fastForward;
|
|
117
|
+
const g = graphics(this.vm);
|
|
118
|
+
if (ff) {
|
|
119
|
+
this.fastForwarding = true;
|
|
120
|
+
this.vm.clock = () => (g.frames * 1000) / 60;
|
|
121
|
+
void this.sound.ctx.suspend();
|
|
122
|
+
}
|
|
123
|
+
const finishFastForward = () => {
|
|
124
|
+
this.fastForwarding = false;
|
|
125
|
+
const base = (g.frames * 1000) / 60, t0 = performance.now();
|
|
126
|
+
this.vm.clock = () => base + (performance.now() - t0);
|
|
127
|
+
if (!this.muted)
|
|
128
|
+
this.sound.unlock();
|
|
129
|
+
ff?.done?.();
|
|
130
|
+
};
|
|
131
|
+
let prev = performance.now();
|
|
132
|
+
let lastFps = prev;
|
|
133
|
+
let counted = 0;
|
|
134
|
+
// SCI32 games expect ~60 FrameOuts per second. If the browser ticks slower (a hidden tab,
|
|
135
|
+
// a slow machine), run several game frames per tick to catch up, up to a cap.
|
|
136
|
+
const FRAME_MS = 1000 / 60;
|
|
137
|
+
let owed = 0;
|
|
138
|
+
const tick = (now) => {
|
|
139
|
+
try {
|
|
140
|
+
if (this.fastForwarding && ff) {
|
|
141
|
+
// As many frames as fit in ~30 ms, then draw so the page stays responsive.
|
|
142
|
+
const stop = performance.now() + 30;
|
|
143
|
+
while (g.frames < ff.until && this.vm.running && performance.now() < stop) {
|
|
144
|
+
ff.script[g.frames]?.();
|
|
145
|
+
this.vm.run();
|
|
146
|
+
this.frames++;
|
|
147
|
+
}
|
|
148
|
+
if (g.frames >= ff.until)
|
|
149
|
+
finishFastForward();
|
|
150
|
+
prev = now;
|
|
151
|
+
}
|
|
152
|
+
owed = this.paused || this.fastForwarding ? 0 : Math.min(owed + (now - prev) / FRAME_MS, 30);
|
|
153
|
+
prev = now;
|
|
154
|
+
let steps = this.paused ? this.stepsOwed : 0;
|
|
155
|
+
this.stepsOwed = 0;
|
|
156
|
+
while ((owed >= 1 || steps > 0) && this.vm.running) {
|
|
157
|
+
this.vm.run();
|
|
158
|
+
this.frames++;
|
|
159
|
+
counted++;
|
|
160
|
+
if (steps > 0)
|
|
161
|
+
steps--;
|
|
162
|
+
else
|
|
163
|
+
owed--;
|
|
164
|
+
}
|
|
165
|
+
if (this.latest)
|
|
166
|
+
this.draw(this.latest);
|
|
167
|
+
for (const hook of this.onDraw)
|
|
168
|
+
hook();
|
|
169
|
+
if (now - lastFps > 1000) {
|
|
170
|
+
this.fps = counted;
|
|
171
|
+
counted = 0;
|
|
172
|
+
lastFps = now;
|
|
173
|
+
}
|
|
174
|
+
if (this.vm.running)
|
|
175
|
+
schedule();
|
|
176
|
+
}
|
|
177
|
+
catch (e) {
|
|
178
|
+
this.error = { message: e.message, backtrace: this.vm.backtrace() };
|
|
179
|
+
console.error(e);
|
|
180
|
+
for (const hook of this.onDraw)
|
|
181
|
+
hook();
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
// One tick per animation frame; with runHidden, a timer fires it if frames stop coming.
|
|
185
|
+
let pending = false;
|
|
186
|
+
const fire = (now) => {
|
|
187
|
+
if (!pending)
|
|
188
|
+
return;
|
|
189
|
+
pending = false;
|
|
190
|
+
tick(now);
|
|
191
|
+
};
|
|
192
|
+
const schedule = () => {
|
|
193
|
+
pending = true;
|
|
194
|
+
requestAnimationFrame(fire);
|
|
195
|
+
if (this.options.runHidden)
|
|
196
|
+
setTimeout(() => fire(performance.now()), 50);
|
|
197
|
+
};
|
|
198
|
+
schedule();
|
|
199
|
+
}
|
|
200
|
+
/** While paused: run `n` game frames on the next tick. */
|
|
201
|
+
step(n = 1) {
|
|
202
|
+
this.stepsOwed += n;
|
|
203
|
+
}
|
|
204
|
+
snapshot() {
|
|
205
|
+
return snapshot(this.vm);
|
|
206
|
+
}
|
|
207
|
+
/** Like the game's own restore: replace memory, then restart execution with `replay`. */
|
|
208
|
+
/** Loads a snapshot as the game's own Restore does (see `restoreGame`). */
|
|
209
|
+
restore(snap) {
|
|
210
|
+
restoreGame(this.vm, snap);
|
|
211
|
+
}
|
|
212
|
+
draw(f) {
|
|
213
|
+
const { pixels, palette, rgb } = f;
|
|
214
|
+
const data = this.image.data;
|
|
215
|
+
for (let i = 0; i < pixels.length; i++) {
|
|
216
|
+
// Mid-transition frames carry RGB directly; otherwise look colours up in the palette.
|
|
217
|
+
const src = rgb ?? palette.rgb;
|
|
218
|
+
const c = rgb ? i * 3 : pixels[i] * 3;
|
|
219
|
+
data[i * 4] = src[c];
|
|
220
|
+
data[i * 4 + 1] = src[c + 1];
|
|
221
|
+
data[i * 4 + 2] = src[c + 2];
|
|
222
|
+
data[i * 4 + 3] = 255;
|
|
223
|
+
}
|
|
224
|
+
this.ctx.putImageData(this.image, 0, 0);
|
|
225
|
+
}
|
|
226
|
+
// --- Input: map page coordinates to the 320×200 game screen ---------------------
|
|
227
|
+
attachInput() {
|
|
228
|
+
const { canvas } = this;
|
|
229
|
+
const inp = input(this.vm);
|
|
230
|
+
const toGame = (e) => {
|
|
231
|
+
const r = canvas.getBoundingClientRect();
|
|
232
|
+
return {
|
|
233
|
+
x: Math.max(0, Math.min(319, Math.floor(((e.clientX - r.left) / r.width) * 320))),
|
|
234
|
+
y: Math.max(0, Math.min(199, Math.floor(((e.clientY - r.top) / r.height) * 200))),
|
|
235
|
+
};
|
|
236
|
+
};
|
|
237
|
+
// While a fast-forward plays its script, the player's mouse and keys stay out of it: a
|
|
238
|
+
// queued click reads the mouse position when the game gets to it, so a real mouse move in
|
|
239
|
+
// between would send the scripted click wherever the pointer is.
|
|
240
|
+
const track = (e) => {
|
|
241
|
+
if (!this.fastForwarding)
|
|
242
|
+
({ x: inp.x, y: inp.y } = toGame(e));
|
|
243
|
+
};
|
|
244
|
+
let swallowed = false;
|
|
245
|
+
canvas.addEventListener("mousemove", track);
|
|
246
|
+
canvas.addEventListener("contextmenu", (e) => e.preventDefault());
|
|
247
|
+
canvas.addEventListener("mousedown", (e) => {
|
|
248
|
+
if (this.fastForwarding)
|
|
249
|
+
return;
|
|
250
|
+
const { x, y } = toGame(e);
|
|
251
|
+
swallowed = !!this.options.interceptClick?.(x, y, e);
|
|
252
|
+
if (swallowed)
|
|
253
|
+
return;
|
|
254
|
+
track(e);
|
|
255
|
+
// SCI reports the right button as a click with shift held.
|
|
256
|
+
inp.push({ type: EventType.MouseDown, message: 0, modifiers: e.button === 2 ? 3 : 0 });
|
|
257
|
+
});
|
|
258
|
+
canvas.addEventListener("mouseup", (e) => {
|
|
259
|
+
if (swallowed || this.fastForwarding)
|
|
260
|
+
return;
|
|
261
|
+
track(e);
|
|
262
|
+
inp.push({ type: EventType.MouseUp, message: 0 });
|
|
263
|
+
});
|
|
264
|
+
window.addEventListener("keydown", (e) => {
|
|
265
|
+
if (e.metaKey || !canvas.isConnected || this.fastForwarding)
|
|
266
|
+
return; // leave browser shortcuts alone
|
|
267
|
+
// Typing into the page's own inputs (search boxes, the room editor) isn't for the game.
|
|
268
|
+
const target = e.target;
|
|
269
|
+
if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT" || target.isContentEditable))
|
|
270
|
+
return;
|
|
271
|
+
// DOS reports Alt+letter as the key's scan code in the high byte (Alt-T = 0x1400); use
|
|
272
|
+
// the physical key, since on a Mac Alt changes the character (Alt-T types "†").
|
|
273
|
+
const scan = e.altKey ? ALT_SCAN_CODES[e.code] : undefined;
|
|
274
|
+
const code = scan ? scan << 8 : e.key === "Tab" && e.shiftKey ? SciKey.ShiftTab : KEY_CODES[e.key] ?? (e.key.length === 1 ? e.key.charCodeAt(0) : 0);
|
|
275
|
+
if (!code)
|
|
276
|
+
return;
|
|
277
|
+
e.preventDefault();
|
|
278
|
+
inp.push({ type: EventType.KeyDown, message: code, modifiers: modifiers(e) });
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const KEY_CODES = {
|
|
283
|
+
Enter: 13, Escape: 27, Backspace: 8, Tab: 9, " ": 32,
|
|
284
|
+
ArrowUp: SciKey.Up, ArrowDown: SciKey.Down, ArrowLeft: SciKey.Left, ArrowRight: SciKey.Right,
|
|
285
|
+
Home: SciKey.Home, End: SciKey.End, PageUp: SciKey.PageUp, PageDown: SciKey.PageDown,
|
|
286
|
+
Insert: SciKey.Insert, Delete: SciKey.Delete, Clear: SciKey.Center,
|
|
287
|
+
F1: SciKey.F1, F2: SciKey.F2, F3: SciKey.F3, F4: SciKey.F4, F5: SciKey.F5,
|
|
288
|
+
F6: SciKey.F6, F7: SciKey.F7, F8: SciKey.F8, F9: SciKey.F9, F10: SciKey.F10,
|
|
289
|
+
};
|
|
290
|
+
/** PC scan codes for the letter keys (Alt+letter). */
|
|
291
|
+
const ALT_SCAN_CODES = Object.fromEntries([..."QWERTYUIOP"].map((k, i) => [`Key${k}`, 0x10 + i]).concat([..."ASDFGHJKL"].map((k, i) => [`Key${k}`, 0x1e + i]), [..."ZXCVBNM"].map((k, i) => [`Key${k}`, 0x2c + i])));
|
|
292
|
+
/** SCI modifier bits: 3 = shift (both), 4 = ctrl, 8 = alt. */
|
|
293
|
+
const modifiers = (e) => (e.shiftKey ? 3 : 0) | (e.ctrlKey ? 4 : 0) | (e.altKey ? 8 : 0);
|
|
294
|
+
function readMusicMode() {
|
|
295
|
+
try {
|
|
296
|
+
return localStorage.getItem(`${APP_ID}.music`) === "adlib" ? "adlib" : "gm";
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
return "gm";
|
|
300
|
+
}
|
|
301
|
+
}
|