@lewismoten/gamemaker-sandbox-engine 3.1.0 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/NOTICE.md +58 -753
- package/README.md +94 -10
- package/dist/assets/{music.worklet-NqOeNo6T.js → music.worklet-aD2x7RRM.js} +23 -21
- package/dist/audio/cmfLoad.js +10 -0
- package/dist/audio/cmfPlayer.js +201 -0
- package/dist/audio/context.js +75 -0
- package/dist/audio/engine.js +173 -0
- package/dist/audio/mix.js +40 -0
- package/dist/audio/opl.js +245 -0
- package/dist/audio/output.js +40 -0
- package/dist/core/gameFiles.js +89 -0
- package/dist/engine/character.js +187 -0
- package/dist/engine/funcKeys.js +164 -0
- package/dist/engine/game.js +133 -0
- package/dist/engine/keys.js +68 -0
- package/dist/engine/machine.js +192 -0
- package/dist/engine/menus.js +135 -0
- package/dist/engine/monsters.js +259 -0
- package/dist/engine/playGain.js +10 -0
- package/dist/engine/playerLook.js +48 -0
- package/dist/engine/recorder.js +127 -0
- package/dist/engine/render.js +45 -0
- package/dist/engine/scene.js +324 -0
- package/dist/engine/scroll.js +79 -0
- package/dist/engine/showGraphic.js +107 -0
- package/dist/engine/slowPc.js +98 -0
- package/dist/engine/slowPcMusic.js +46 -0
- package/dist/engine/touch.js +61 -0
- package/dist/engine/world.js +205 -0
- package/dist/formats/binary.js +120 -0
- package/dist/formats/blocks.js +103 -0
- package/dist/formats/character.js +83 -0
- package/dist/formats/cmf.js +37 -0
- package/dist/formats/cmfEdit.js +154 -0
- package/dist/formats/cmfPreview.js +89 -0
- package/dist/formats/cmfSong.js +190 -0
- package/dist/formats/fli.js +340 -0
- package/dist/formats/fliFrames.js +34 -0
- package/dist/formats/fliLimits.js +54 -0
- package/dist/formats/game.js +198 -0
- package/dist/formats/gif.js +125 -0
- package/dist/formats/gifProgressive.js +99 -0
- package/dist/formats/graphicScripts.js +44 -0
- package/dist/formats/highscore.js +30 -0
- package/dist/formats/jpeg.js +359 -0
- package/dist/formats/map.js +38 -0
- package/dist/formats/monster.js +69 -0
- package/dist/formats/palette.js +58 -0
- package/dist/formats/recording.js +17 -0
- package/dist/formats/sound.js +44 -0
- package/dist/formats/voc.js +303 -0
- package/dist/gfx/colorVision.js +45 -0
- package/dist/gfx/cp437Glyphs.js +337 -0
- package/dist/gfx/font8x8.js +10 -0
- package/dist/gfx/screen.js +151 -0
- package/dist/index.js +15 -0
- package/dist/input/chords.js +46 -0
- package/dist/input/deskMouse.js +24 -0
- package/dist/input/keyboard.js +82 -0
- package/dist/input/scancodes.js +143 -0
- package/dist/player/fileExtensions.js +118 -0
- package/dist/player/patchRules.js +129 -0
- package/dist/player/patchTable.js +1753 -0
- package/dist/text/cp437.js +44 -0
- package/dist/types/audio/cmfPlayer.d.ts +1 -1
- package/dist/types/audio/engine.d.ts +21 -0
- package/dist/types/audio/mix.d.ts +14 -0
- package/dist/types/audio/music.worklet.d.ts +8 -0
- package/dist/types/engine/character.d.ts +3 -3
- package/dist/types/engine/funcKeys.d.ts +8 -6
- package/dist/types/engine/game.d.ts +9 -4
- package/dist/types/engine/keys.d.ts +2 -2
- package/dist/types/engine/machine.d.ts +8 -4
- package/dist/types/engine/menus.d.ts +4 -4
- package/dist/types/engine/monsters.d.ts +1 -1
- package/dist/types/engine/playerLook.d.ts +20 -2
- package/dist/types/engine/recorder.d.ts +8 -1
- package/dist/types/engine/render.d.ts +2 -2
- package/dist/types/engine/scene.d.ts +41 -9
- package/dist/types/engine/scroll.d.ts +9 -1
- package/dist/types/engine/showGraphic.d.ts +2 -2
- package/dist/types/engine/slowPc.d.ts +64 -2
- package/dist/types/engine/slowPcMusic.d.ts +18 -0
- package/dist/types/engine/touch.d.ts +2 -2
- package/dist/types/engine/world.d.ts +28 -6
- package/dist/types/formats/cmfEdit.d.ts +53 -0
- package/dist/types/formats/cmfPreview.d.ts +19 -0
- package/dist/types/formats/cmfSong.d.ts +74 -0
- package/dist/types/formats/fli.d.ts +11 -0
- package/dist/types/formats/fliFrames.d.ts +18 -0
- package/dist/types/formats/fliLimits.d.ts +13 -0
- package/dist/types/formats/game.d.ts +7 -0
- package/dist/types/formats/gif.d.ts +1 -1
- package/dist/types/formats/gifProgressive.d.ts +46 -0
- package/dist/types/formats/jpeg.d.ts +20 -0
- package/dist/types/gfx/colorVision.d.ts +26 -0
- package/dist/types/gfx/screen.d.ts +4 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/input/keyboard.d.ts +1 -1
- package/dist/types/{dosPlayer → player}/patchRules.d.ts +1 -1
- package/dist/types/{dosPlayer → player}/patchTable.d.ts +8 -5
- package/dist/types/text/cp437.d.ts +6 -0
- package/package.json +322 -9
- package/dist/engine.js +0 -5436
- package/dist/types/engine/package.d.ts +0 -4
- /package/dist/types/{project → core}/gameFiles.d.ts +0 -0
- /package/dist/types/{dosPlayer → player}/fileExtensions.d.ts +0 -0
package/README.md
CHANGED
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
`@lewismoten/gamemaker-sandbox-engine` runs RSD Game-Maker games from their original DOS files in a browser. It is the same TypeScript engine used by the Game-Maker: Sandbox Edition web player. The package includes its audio worklet and TypeScript declarations; it does not include games or the site's editor and catalog.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Install from your configured npm registry:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @lewismoten/gamemaker-sandbox-engine
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
If your registry requires authentication or a scope mapping, configure npm using that registry's instructions.
|
|
6
12
|
|
|
7
13
|
Pass the files from one game's DOS directory to `GameFiles`. Names are case insensitive and use DOS 8.3 names. `GameFiles.mainGame()` finds a `.GAM` file. Keep the original file names and bytes; the game refers to its other files by name.
|
|
8
14
|
|
|
@@ -51,16 +57,94 @@ play.addEventListener('click', async () => {
|
|
|
51
57
|
|
|
52
58
|
`Machine` owns the screen, keyboard and audio. Call `machine.destroy()` when play finishes or the containing page closes. `runGame` accepts `RunOptions` for callbacks, joystick input, playback and the optional DOS player appearance. The game may write saves or high scores into `GameFiles`; use `files.onChange` to persist those bytes. The package targets modern browsers and ES modules.
|
|
53
59
|
|
|
60
|
+
`machine.screen.dosCropped` is observer metadata for PLAYGAME’s CropScreen state: true during level rendering and its fades, false for function-key screens and after a level. `sceneNumber` identifies the last rendered level. These fields do not change rendering or game timing.
|
|
61
|
+
|
|
62
|
+
## Read a game file in Node.js
|
|
63
|
+
|
|
64
|
+
The format modules need no browser. This reads a `.GAM` file from the original CD (run it from the repository root) and lists its scene titles:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { readFileSync } from 'node:fs';
|
|
68
|
+
import { parseGame } from '@lewismoten/gamemaker-sandbox-engine/formats/game';
|
|
69
|
+
import { fromCp437 } from '@lewismoten/gamemaker-sandbox-engine/text/cp437';
|
|
70
|
+
|
|
71
|
+
const game = parseGame(readFileSync('cd/gameware/peach/peach.gam'));
|
|
72
|
+
console.log(`${game.scenes.length} scenes: ${game.scenes.slice(2, 4).map((s) => fromCp437(s.desc)).join(' and ')}`);
|
|
73
|
+
// prints: 20 scenes: Grasslands 1 and Grasslands 2
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
A test (`web/test/readmeExamples.test.ts`) runs this block in Node against the built package and compares what it prints with the `// prints:` line.
|
|
77
|
+
|
|
78
|
+
Parsed descriptions are DOS byte strings, not Unicode. For a game name outside DOS use
|
|
79
|
+
`gameTitle(game)` from `formats/game`: it decodes CP437 and trims decorative menu frames.
|
|
80
|
+
For other DOS labels use `fromCp437`; for DOS text-file bytes use `cp437Text` from `text/cp437`,
|
|
81
|
+
which retains ASCII controls so the caller can handle line endings and DOS EOF. Neither API
|
|
82
|
+
is for already decoded Unicode, JSON, HTML or UTF-8 text. Keep the parsed fields and original
|
|
83
|
+
bytes for DOS rendering and round trips; convert only at the presentation boundary.
|
|
84
|
+
|
|
85
|
+
## Native player screens
|
|
86
|
+
|
|
87
|
+
The package includes the complete DOS player front end. The example above shows
|
|
88
|
+
its title images or animation and menu before play. The menu offers instructions,
|
|
89
|
+
storyline, credits, top scores and quitting; during play the native function keys
|
|
90
|
+
open help, inventory, load and save screens. Include the files named by the game's
|
|
91
|
+
`.GAM` file, along with any saved games or high scores you want to keep.
|
|
92
|
+
|
|
93
|
+
`runGame` uses these screens by default. For a direct play test, pass
|
|
94
|
+
`{ skipTitle: true }` to start a new game at its initial scene link; its normal
|
|
95
|
+
ending screens and menu still follow when play ends. `RunOptions.player` accepts
|
|
96
|
+
DOS player customizations for words, colors, frames, positions, timing and function
|
|
97
|
+
keys. `gmHelp` optionally supplies Game-Maker's own help text before the game's
|
|
98
|
+
instructions. See the exported `RunOptions` type for these settings and callbacks.
|
|
99
|
+
|
|
100
|
+
Observe actual life loss with `onLifeLost`, including the last life, or the final
|
|
101
|
+
result and score with `onPlayEnd`. Recorded demo playback does not emit either
|
|
102
|
+
callback. Persist `GameFiles.onChange` to retain save files and high scores between
|
|
103
|
+
visits.
|
|
104
|
+
|
|
54
105
|
The source is MIT licensed; see `LICENSE` and `NOTICE.md` for credits.
|
|
55
106
|
|
|
56
|
-
|
|
107
|
+
Hosts can call `machine.audio.setSoundCard(enabled)` to change the optional sound-card
|
|
108
|
+
output during play. Removing it pauses FM and cuts digital effects without stopping the
|
|
109
|
+
PC speaker. Restoring it resumes the loaded song, or starts the latest song requested
|
|
110
|
+
while absent. `soundCardEnabled` reports the current card state; returning the card
|
|
111
|
+
preserves a disabled music preference. An explicit song stop remains stopped. Normal DOS-compatible startup
|
|
112
|
+
behavior is unchanged unless the host opts into live card control.
|
|
113
|
+
Set the audio card state before `runGame` and call the same method for later switches;
|
|
114
|
+
effects then use the PC speaker while the card is absent. `RunOptions.noSoundCard` retains
|
|
115
|
+
the original startup-only detection, including its fixed speaker-only choice for that run.
|
|
116
|
+
|
|
117
|
+
## Incremental picture decoding
|
|
118
|
+
|
|
119
|
+
`formats/jpeg` exports `decodePartialJpeg(bytes)` for baseline and progressive Huffman JPEGs.
|
|
120
|
+
Pass only the bytes received so far. `shown` marks pixels with completed luminance/DC blocks;
|
|
121
|
+
`pixels` holds grayscale or RGB data. A partial first progressive scan reveals only its
|
|
122
|
+
completed blocks; later scans retain the previous coefficients until each MCU is complete.
|
|
123
|
+
Restart markers preserve already decoded MCUs. `scans` counts fully delimited scans and
|
|
124
|
+
`complete` reports an end-of-image marker. The decoder does not invent data for unread blocks.
|
|
125
|
+
|
|
126
|
+
`RunOptions.onLifeLost` observes each player death, including the final life.
|
|
127
|
+
`onPlayEnd(score, how, gained)` reports `won`, `lost`, or `quit` once a play ends.
|
|
128
|
+
Neither callback runs during demo or walkthrough playback. These callbacks do not
|
|
129
|
+
change DOS gameplay or the game files.
|
|
130
|
+
|
|
131
|
+
`Machine.onItem` is an observer hook: `showGraphic` calls it as each picture, cartoon, text page or slide list begins (the walkthrough player's item marks). It never changes timing or behavior.
|
|
132
|
+
|
|
133
|
+
## Subpath exports
|
|
134
|
+
|
|
135
|
+
Everything is also importable from its own subpath, for example `import { parseBlocks } from '@lewismoten/gamemaker-sandbox-engine/formats/blocks'`, so a Node script or a bundler can take one module without the player:
|
|
136
|
+
|
|
137
|
+
- `formats/*`: readers and writers for the DOS files: `blocks`, `character`, `cmf`, `cmfEdit`, `cmfPreview`, `cmfSong`, `fli`, `fliFrames`, `fliLimits`, `game`, `gif`, `gifProgressive`, `graphicScripts`, `highscore`, `jpeg`, `map`, `monster`, `palette`, `recording`, `sound`, `voc`, and `binary` (byte helpers).
|
|
138
|
+
- `engine/*`: the PLAYGAME port: `machine`, `game`, `world`, `scene`, `scroll`, `render`, `showGraphic`, `menus`, `monsters`, `character`, `keys`, `funcKeys`, `touch`, `recorder`, `playGain`, `playerLook`, `slowPc`.
|
|
139
|
+
- `audio/*`: OPL2 and CMF playback: `opl`, `cmfPlayer`, `cmfLoad`, `engine`, `mix`, `output`, `context`.
|
|
140
|
+
- `gfx/*`: `screen`, `font8x8`, `cp437Glyphs`, `colorVision` (color-vision simulation matrices); `text/cp437`.
|
|
141
|
+
- `input/*`: `keyboard`, `scancodes`, `chords`, `deskMouse`.
|
|
142
|
+
- `core/gameFiles` (`GameFiles`) and `player/*` (`fileExtensions`, `patchRules`, `patchTable`).
|
|
143
|
+
|
|
144
|
+
## Supported environments
|
|
145
|
+
|
|
146
|
+
Playback needs a browser with canvas, Web Audio and keyboard events (the `Machine` class owns them). The file-format, decoding and simulation modules have no DOM dependency and also run in Node.js 22.12 or later, which is the version the package declares in `engines`. It ships as ES modules with TypeScript declarations.
|
|
57
147
|
|
|
58
|
-
|
|
59
|
-
GitHub Packages when an `engine-v<version>` tag is pushed. The tagged commit must be on
|
|
60
|
-
`master`, and `<version>` must exactly match this package's `package.json` (for example,
|
|
61
|
-
`engine-v3.1.0`). Merge the package changes to `master` before pushing the tag.
|
|
148
|
+
## Developing
|
|
62
149
|
|
|
63
|
-
|
|
64
|
-
builds the package, and publishes it to GitHub Packages. No package is published by ordinary
|
|
65
|
-
branch pushes. Publishing to npm is a separate, manual decision; this workflow does not use
|
|
66
|
-
an npm token.
|
|
150
|
+
This package's source lives in [the project's repository](https://github.com/lewismoten/GameMaker-SandboxEdition), in `web/packages/engine`. The folder holds all its source in `src/`, plus its build configuration, license and notices. It can be copied outside the repository: run `npm install` and `npm run verify` there to build and test a packed consumer. Inside the repository, run `npm ci` at the root, then `npm run build -w @lewismoten/gamemaker-sandbox-engine`. Run `npm pack` in the package folder to inspect a local distribution. Tarballs are build artifacts and should not be committed. The [monorepo guide](https://github.com/lewismoten/GameMaker-SandboxEdition/blob/master/docs/monorepo.md) covers the workspace layout, and the [package release guide](https://github.com/lewismoten/GameMaker-SandboxEdition/blob/master/docs/package-releases.md) covers publishing.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const g = [0.5, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 10, 12, 12, 15, 15], M = [0, 9, 12, 13.875, 15, 16.125, 16.875, 17.625, 18, 18.75, 19.125, 19.5, 19.875, 20.25, 20.625, 21], v = [0, 0.5, 0.25, 1], O = 49716,
|
|
1
|
+
const g = [0.5, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 10, 12, 12, 15, 15], M = [0, 9, 12, 13.875, 15, 16.125, 16.875, 17.625, 18, 18.75, 19.125, 19.5, 19.875, 20.25, 20.625, 21], v = [0, 0.5, 0.25, 1], O = 49716, c = 96, D = [0, 1, 2, 3, 4, 5, 8, 9, 10, 11, 12, 13, 16, 17, 18, 19, 20, 21];
|
|
2
2
|
class b {
|
|
3
3
|
am = !1;
|
|
4
4
|
vib = !1;
|
|
@@ -17,7 +17,7 @@ class b {
|
|
|
17
17
|
phase = 0;
|
|
18
18
|
// cycles
|
|
19
19
|
stage = 0;
|
|
20
|
-
envDb =
|
|
20
|
+
envDb = c;
|
|
21
21
|
out = 0;
|
|
22
22
|
prevOut = 0;
|
|
23
23
|
keyOn() {
|
|
@@ -60,11 +60,11 @@ class b {
|
|
|
60
60
|
default:
|
|
61
61
|
return;
|
|
62
62
|
}
|
|
63
|
-
this.envDb >=
|
|
63
|
+
this.envDb >= c && (this.envDb = c, this.stage = 0);
|
|
64
64
|
}
|
|
65
65
|
decayDbPerSecond(t, s) {
|
|
66
66
|
const e = this.rate(t, s);
|
|
67
|
-
return e === 0 ? 0 :
|
|
67
|
+
return e === 0 ? 0 : c / (39.28 / Math.pow(2, (e - 4) / 4));
|
|
68
68
|
}
|
|
69
69
|
/** Waveform value at phase (cycles): 0 sine, 1 half sine, 2 absolute sine, 3 pulse (quarter) sine. */
|
|
70
70
|
wave01(t) {
|
|
@@ -119,7 +119,7 @@ class P {
|
|
|
119
119
|
for (let t = 0; t < 256; t++) this.write(t, 0);
|
|
120
120
|
for (const t of this.channels)
|
|
121
121
|
for (const s of t.ops)
|
|
122
|
-
s.stage = 0, s.envDb =
|
|
122
|
+
s.stage = 0, s.envDb = c;
|
|
123
123
|
}
|
|
124
124
|
read(t) {
|
|
125
125
|
return this.regs[t & 255];
|
|
@@ -198,14 +198,14 @@ class P {
|
|
|
198
198
|
const r = this.noise & 1 ? 1 : -1;
|
|
199
199
|
let n = 0;
|
|
200
200
|
const f = this.rhythm ? 6 : 9;
|
|
201
|
-
for (let
|
|
201
|
+
for (let l = 0; l < f; l++) n += this.channelSample(this.channels[l], e, i, a);
|
|
202
202
|
this.rhythm && (n += this.rhythmSample(e, i, a, r)), t[h] += n * s;
|
|
203
203
|
}
|
|
204
204
|
}
|
|
205
205
|
/** Linear amplitude from the summed attenuation: envelope + TL (0.75 dB steps) + KSL + tremolo. */
|
|
206
206
|
opLevel(t, s, e) {
|
|
207
207
|
const h = s.envDb + s.tl * 0.75 + t.kslDb(s) + (s.am ? e : 0);
|
|
208
|
-
return h >=
|
|
208
|
+
return h >= c ? 0 : Math.pow(10, -h / 20);
|
|
209
209
|
}
|
|
210
210
|
advance(t, s, e, h) {
|
|
211
211
|
s.phase += t.freq * s.mult * (s.vib ? h : 1) * e, s.phase > 1e6 && (s.phase -= Math.floor(s.phase)), s.stepEnvelope(e, t.rof(this.noteSel));
|
|
@@ -227,18 +227,18 @@ class P {
|
|
|
227
227
|
rhythmSample(t, s, e, h) {
|
|
228
228
|
const [i, o, a] = [this.channels[6], this.channels[7], this.channels[8]];
|
|
229
229
|
let r = this.channelSample(i, t, s, e) * 2;
|
|
230
|
-
const [n, f] = o.ops, [
|
|
230
|
+
const [n, f] = o.ops, [l, p] = a.ops;
|
|
231
231
|
for (const [w, k] of [
|
|
232
232
|
[o, n],
|
|
233
233
|
[o, f],
|
|
234
|
-
[a,
|
|
234
|
+
[a, l],
|
|
235
235
|
[a, p]
|
|
236
236
|
])
|
|
237
237
|
this.advance(w, k, t, e);
|
|
238
|
-
return n.stage !== 0 && (r += h * 0.5 * this.opLevel(o, n, s) * (n.wave01(n.phase) > 0 ? 1 : 0.6) * 2), f.stage !== 0 && (r += (f.wave01(f.phase) * 0.6 + h * 0.4) * this.opLevel(o, f, s) * 2),
|
|
238
|
+
return n.stage !== 0 && (r += h * 0.5 * this.opLevel(o, n, s) * (n.wave01(n.phase) > 0 ? 1 : 0.6) * 2), f.stage !== 0 && (r += (f.wave01(f.phase) * 0.6 + h * 0.4) * this.opLevel(o, f, s) * 2), l.stage !== 0 && (r += l.wave01(l.phase) * this.opLevel(a, l, s) * 2), p.stage !== 0 && (r += (p.wave01(p.phase) > 0 ? 0.5 : -0.5) * (h > 0 ? 1 : 0.7) * this.opLevel(a, p, s) * 2), r;
|
|
239
239
|
}
|
|
240
240
|
}
|
|
241
|
-
const y = [0, 1, 2, 8, 9, 10, 16, 17, 18], T = 1193180 / Math.floor(65535 / 8),
|
|
241
|
+
const y = [0, 1, 2, 8, 9, 10, 16, 17, 18], T = 1193180 / Math.floor(65535 / 8), d = { 11: 16, 12: 8, 13: 4, 14: 2, 15: 1 }, u = {
|
|
242
242
|
11: { ch: 6, ops: ["mod", "car"] },
|
|
243
243
|
12: { ch: 7, ops: ["car"] },
|
|
244
244
|
13: { ch: 8, ops: ["mod"] },
|
|
@@ -362,8 +362,8 @@ class K {
|
|
|
362
362
|
this.programs[t] = s;
|
|
363
363
|
const e = this.instruments[s];
|
|
364
364
|
if (e) {
|
|
365
|
-
if (this.rhythm &&
|
|
366
|
-
const h =
|
|
365
|
+
if (this.rhythm && u[t]) {
|
|
366
|
+
const h = u[t];
|
|
367
367
|
h.ops.length === 2 ? this.loadVoice(h.ch, e) : this.loadOperator(y[h.ch] + (h.ops[0] === "car" ? 3 : 0), e, 0);
|
|
368
368
|
return;
|
|
369
369
|
}
|
|
@@ -405,7 +405,7 @@ class K {
|
|
|
405
405
|
const e = this.instruments[this.programs[t]];
|
|
406
406
|
if (!e) return;
|
|
407
407
|
let h, i;
|
|
408
|
-
const o = this.rhythm ?
|
|
408
|
+
const o = this.rhythm ? u[t] : void 0;
|
|
409
409
|
if (o && o.ops.length === 1)
|
|
410
410
|
h = y[o.ch] + (o.ops[0] === "car" ? 3 : 0), i = e[2];
|
|
411
411
|
else {
|
|
@@ -417,17 +417,17 @@ class K {
|
|
|
417
417
|
this.opl.write(64 + h, i & 192 | a);
|
|
418
418
|
}
|
|
419
419
|
noteOn(t, s, e = 127) {
|
|
420
|
-
if (this.applyVelocity(t, e), this.rhythm &&
|
|
421
|
-
const h =
|
|
422
|
-
this.setPitch(
|
|
420
|
+
if (this.applyVelocity(t, e), this.rhythm && d[t]) {
|
|
421
|
+
const h = d[t];
|
|
422
|
+
this.setPitch(u[t].ch, s, !1), this.rhythmKeys &= ~h, this.opl.write(189, 224 | this.rhythmKeys), this.rhythmKeys |= h, this.opl.write(189, 224 | this.rhythmKeys), this.notes[t] = s;
|
|
423
423
|
return;
|
|
424
424
|
}
|
|
425
425
|
t >= this.melodyVoices() || (this.notes[t] >= 0 && this.opl.write(176 + t, this.opl.read(176 + t) & -33), this.setPitch(t, s, !0), this.notes[t] = s);
|
|
426
426
|
}
|
|
427
427
|
noteOff(t, s) {
|
|
428
428
|
if (this.notes[t] === s) {
|
|
429
|
-
if (this.notes[t] = -1, this.rhythm &&
|
|
430
|
-
this.rhythmKeys &= ~
|
|
429
|
+
if (this.notes[t] = -1, this.rhythm && d[t]) {
|
|
430
|
+
this.rhythmKeys &= ~d[t], this.opl.write(189, 224 | this.rhythmKeys);
|
|
431
431
|
return;
|
|
432
432
|
}
|
|
433
433
|
t < this.melodyVoices() && this.opl.write(176 + t, this.opl.read(176 + t) & -33);
|
|
@@ -438,13 +438,15 @@ class L extends AudioWorkletProcessor {
|
|
|
438
438
|
opl = new P(sampleRate);
|
|
439
439
|
player = new K(this.opl);
|
|
440
440
|
untilTick = 0;
|
|
441
|
+
/** Samples left of a hold: the chip renders, the sequencer does not move. */
|
|
442
|
+
held = 0;
|
|
441
443
|
paused = !1;
|
|
442
444
|
ended = !1;
|
|
443
445
|
samplesPerTick = sampleRate / T;
|
|
444
446
|
constructor() {
|
|
445
447
|
super(), this.port.onmessage = (t) => {
|
|
446
448
|
const s = t.data;
|
|
447
|
-
s.type === "load" ? (this.player.load(s.music, s.instruments), this.untilTick = 0) : s.type === "stop" ? this.player.stop() : s.type === "pause" ? this.paused = s.paused : s.type === "end" && (this.ended = !0);
|
|
449
|
+
s.type === "load" ? (this.player.load(s.music, s.instruments), this.untilTick = 0, this.held = 0) : s.type === "hold" ? this.held += s.ms * sampleRate / 1e3 : s.type === "stop" ? this.player.stop() : s.type === "pause" ? this.paused = s.paused : s.type === "end" && (this.ended = !0);
|
|
448
450
|
};
|
|
449
451
|
}
|
|
450
452
|
process(t, s) {
|
|
@@ -453,7 +455,7 @@ class L extends AudioWorkletProcessor {
|
|
|
453
455
|
if (!e || this.paused) return !0;
|
|
454
456
|
let h = 0;
|
|
455
457
|
for (; h < e.length; ) {
|
|
456
|
-
this.untilTick <= 0 && (this.player.tick(), this.untilTick += this.samplesPerTick);
|
|
458
|
+
this.untilTick <= 0 && (this.held > 0 ? this.held -= this.samplesPerTick : this.player.tick(), this.untilTick += this.samplesPerTick);
|
|
457
459
|
const i = Math.min(e.length - h, Math.ceil(this.untilTick));
|
|
458
460
|
this.opl.render(e.subarray(h, h + i)), this.untilTick -= i, h += i;
|
|
459
461
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { isCmf as r, parseCmf as o, CMF_GAMEMAKER_CLOCK as m } from "../formats/cmf.js";
|
|
2
|
+
function c(i) {
|
|
3
|
+
if (!i || !r(i) || (i[36] | i[37] << 8) * 16 & 32768) return;
|
|
4
|
+
const n = o(i);
|
|
5
|
+
if (n.clockTicks === m)
|
|
6
|
+
return { music: n.music, instruments: n.instruments };
|
|
7
|
+
}
|
|
8
|
+
export {
|
|
9
|
+
c as loadSong
|
|
10
|
+
};
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { MODULATOR_OFFSET as r } from "./opl.js";
|
|
2
|
+
const m = 1193180 / Math.floor(65535 / 8), f = { 11: 16, 12: 8, 13: 4, 14: 2, 15: 1 }, l = {
|
|
3
|
+
11: { ch: 6, ops: ["mod", "car"] },
|
|
4
|
+
12: { ch: 7, ops: ["car"] },
|
|
5
|
+
13: { ch: 8, ops: ["mod"] },
|
|
6
|
+
14: { ch: 8, ops: ["car"] },
|
|
7
|
+
15: { ch: 7, ops: ["mod"] }
|
|
8
|
+
};
|
|
9
|
+
class n {
|
|
10
|
+
opl;
|
|
11
|
+
music = new Uint8Array(0);
|
|
12
|
+
instruments = [];
|
|
13
|
+
// PlayIt's statics: read position in the music block, ticks left before the next
|
|
14
|
+
// event, and the running status (channel and 8 = note off / 9 = note on).
|
|
15
|
+
offset = 0;
|
|
16
|
+
wait = 0;
|
|
17
|
+
channel = 0;
|
|
18
|
+
funct = 8;
|
|
19
|
+
rhythm = !1;
|
|
20
|
+
rhythmKeys = 0;
|
|
21
|
+
// Note sounding on each MIDI channel (-1 none), so a note off only releases its own note.
|
|
22
|
+
notes = new Int16Array(16).fill(-1);
|
|
23
|
+
programs = new Uint8Array(16);
|
|
24
|
+
playing = !1;
|
|
25
|
+
constructor(t) {
|
|
26
|
+
this.opl = t, this.init();
|
|
27
|
+
}
|
|
28
|
+
/** sbfd_init: enable waveform select, melody mode (0xBD = deep tremolo and vibrato, rhythm off). */
|
|
29
|
+
init() {
|
|
30
|
+
this.opl.reset(), this.opl.write(1, 32), this.opl.write(8, 0), this.opl.write(189, 192);
|
|
31
|
+
}
|
|
32
|
+
load(t, s) {
|
|
33
|
+
this.stop(), this.init(), this.music = t, this.instruments = s, this.rhythm = !1, this.rhythmKeys = 0;
|
|
34
|
+
for (let i = 0; i < 16; i++) this.programChange(i, i);
|
|
35
|
+
this.offset = 0, this.playing = t.length > 0;
|
|
36
|
+
}
|
|
37
|
+
/** sbfd_music_off + rewind. */
|
|
38
|
+
stop() {
|
|
39
|
+
this.allNotesOff(), this.offset = 0, this.playing = !1;
|
|
40
|
+
}
|
|
41
|
+
allNotesOff() {
|
|
42
|
+
for (let t = 0; t < 9; t++) this.opl.write(176 + t, this.opl.read(176 + t) & -33);
|
|
43
|
+
this.rhythmKeys = 0, this.opl.write(189, 192 | (this.rhythm ? 32 : 0)), this.notes.fill(-1);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* *(APOS) in SBMUSIC.C. PlayIt read sbmusic+offset with no bounds check, so past the end of
|
|
47
|
+
* the farmalloc'd music block it read whatever heap memory followed. Every CMF ends with the
|
|
48
|
+
* FF 2F 00 end marker, which restarts the song before the read gets there; only a truncated
|
|
49
|
+
* file runs off the end, and for that the port reads 0xFF, which takes the same path as the
|
|
50
|
+
* end marker (sbfd_music_off, offset = 0).
|
|
51
|
+
*/
|
|
52
|
+
byte(t) {
|
|
53
|
+
return this.music[t] ?? 255;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* getpaws() in SBMUSIC.C: reads a MIDI variable length number (7 bits per byte, high bit
|
|
57
|
+
* set on every byte but the last), with no limit on the number of bytes:
|
|
58
|
+
* do { offset++; value = (value << 7) | ((c=*(APOS)) & 0x07F); } while (c & 0x80);
|
|
59
|
+
* value is an unsigned long, so bits shifted past 32 are lost (>>> 0). The one difference:
|
|
60
|
+
* the loop stops at the end of the data, where DOS read on into unknown memory.
|
|
61
|
+
*/
|
|
62
|
+
getPaws() {
|
|
63
|
+
let t = this.byte(this.offset);
|
|
64
|
+
if (t & 128) {
|
|
65
|
+
t &= 127;
|
|
66
|
+
let s;
|
|
67
|
+
do
|
|
68
|
+
this.offset++, s = this.byte(this.offset), t = (t << 7 | s & 127) >>> 0;
|
|
69
|
+
while (s & 128 && this.offset < this.music.length);
|
|
70
|
+
}
|
|
71
|
+
return this.offset++, t;
|
|
72
|
+
}
|
|
73
|
+
/** One timer tick of PlayIt(). */
|
|
74
|
+
tick() {
|
|
75
|
+
if (!this.playing) return;
|
|
76
|
+
this.offset === 0 && (this.wait = 1, this.getPaws()), this.wait--;
|
|
77
|
+
let t = 0;
|
|
78
|
+
for (; this.wait <= 0 && t++ < 1e5; ) {
|
|
79
|
+
const s = this.byte(this.offset);
|
|
80
|
+
switch (s & 240) {
|
|
81
|
+
case 144:
|
|
82
|
+
this.channel = s & 15, this.offset++, this.funct = 9;
|
|
83
|
+
break;
|
|
84
|
+
case 128:
|
|
85
|
+
this.channel = s & 15, this.offset++, this.funct = 8;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
const i = this.byte(this.offset);
|
|
89
|
+
if (i < 128) {
|
|
90
|
+
const h = this.byte(this.offset + 1);
|
|
91
|
+
this.funct === 8 || h === 0 ? this.noteOff(this.channel, i) : this.noteOn(this.channel, i, h), this.offset += 2;
|
|
92
|
+
} else
|
|
93
|
+
switch (i & 240) {
|
|
94
|
+
case 160:
|
|
95
|
+
this.offset += 3;
|
|
96
|
+
break;
|
|
97
|
+
case 176:
|
|
98
|
+
this.byte(this.offset + 1) === 103 && this.setMode(this.byte(this.offset + 2)), this.offset += 3;
|
|
99
|
+
break;
|
|
100
|
+
case 192:
|
|
101
|
+
this.programChange(i & 15, this.byte(this.offset + 1)), this.offset += 2;
|
|
102
|
+
break;
|
|
103
|
+
case 208:
|
|
104
|
+
this.offset += 2;
|
|
105
|
+
break;
|
|
106
|
+
case 224:
|
|
107
|
+
this.offset += 3;
|
|
108
|
+
break;
|
|
109
|
+
case 240:
|
|
110
|
+
i === 247 || i === 240 ? (this.offset++, this.offset += this.getPaws(), this.offset++) : (this.allNotesOff(), this.offset = 0);
|
|
111
|
+
break;
|
|
112
|
+
default:
|
|
113
|
+
this.allNotesOff(), this.offset = 0;
|
|
114
|
+
}
|
|
115
|
+
this.wait = this.getPaws();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** sbfd_setmode: switch melody/rhythm mode and reload the drum channels' instruments. */
|
|
119
|
+
setMode(t) {
|
|
120
|
+
if (this.allNotesOff(), this.rhythm = t !== 0, this.opl.write(189, 192 | (this.rhythm ? 32 : 0)), this.rhythm) for (const s of [11, 12, 13, 14, 15]) this.programChange(s, this.programs[s]);
|
|
121
|
+
}
|
|
122
|
+
programChange(t, s) {
|
|
123
|
+
this.programs[t] = s;
|
|
124
|
+
const i = this.instruments[s];
|
|
125
|
+
if (i) {
|
|
126
|
+
if (this.rhythm && l[t]) {
|
|
127
|
+
const h = l[t];
|
|
128
|
+
h.ops.length === 2 ? this.loadVoice(h.ch, i) : this.loadOperator(r[h.ch] + (h.ops[0] === "car" ? 3 : 0), i, 0);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
t < this.melodyVoices() && this.loadVoice(t, i);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
melodyVoices() {
|
|
135
|
+
return this.rhythm ? 6 : 9;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Writes one operator from a 16 byte SBI instrument record, whose bytes alternate
|
|
139
|
+
* modulator/carrier: 0/1 AM-VIB-EG-KSR-MULT, 2/3 KSL-TL, 4/5 AR-DR, 6/7 SL-RR, 8/9 waveform.
|
|
140
|
+
*/
|
|
141
|
+
loadOperator(t, s, i) {
|
|
142
|
+
this.opl.write(32 + t, s[0 + i]), this.opl.write(64 + t, s[2 + i]), this.opl.write(96 + t, s[4 + i]), this.opl.write(128 + t, s[6 + i]), this.opl.write(224 + t, s[8 + i]);
|
|
143
|
+
}
|
|
144
|
+
loadVoice(t, s) {
|
|
145
|
+
const i = r[t];
|
|
146
|
+
this.loadOperator(i, s, 0), this.loadOperator(i + 3, s, 1), this.opl.write(192 + t, s[10]);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* MIDI note -> OPL frequency: picks the lowest block (octave) whose F-number fits in 10 bits,
|
|
150
|
+
* which keeps the most pitch resolution (Hz = fnum * 49716 / 2^(20 - block)).
|
|
151
|
+
*/
|
|
152
|
+
setPitch(t, s, i) {
|
|
153
|
+
const h = 440 * Math.pow(2, (s - 69) / 12);
|
|
154
|
+
let o, e = 0;
|
|
155
|
+
for (o = 0; o < 8 && (e = Math.round(h * Math.pow(2, 20 - o) / 49716), !(e < 1024)); o++)
|
|
156
|
+
;
|
|
157
|
+
o = Math.min(o, 7), e = Math.min(e, 1023), this.opl.write(160 + t, e & 255), this.opl.write(176 + t, (i ? 32 : 0) | o << 2 | e >> 8);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* SBFMDRV's loudness by velocity: it scales the instrument's carrier level (TL is an
|
|
161
|
+
* attenuation, 0 loudest .. 63 silent) and keeps the key-scaling bits:
|
|
162
|
+
* TL = 63 - floor((velocity + 128) * (63 - instrumentTL) / 256). So velocity 127 plays
|
|
163
|
+
* the instrument almost as loud as written and velocity 1 about half as loud in TL steps.
|
|
164
|
+
*/
|
|
165
|
+
applyVelocity(t, s) {
|
|
166
|
+
const i = this.instruments[this.programs[t]];
|
|
167
|
+
if (!i) return;
|
|
168
|
+
let h, o;
|
|
169
|
+
const e = this.rhythm ? l[t] : void 0;
|
|
170
|
+
if (e && e.ops.length === 1)
|
|
171
|
+
h = r[e.ch] + (e.ops[0] === "car" ? 3 : 0), o = i[2];
|
|
172
|
+
else {
|
|
173
|
+
const a = e ? e.ch : t;
|
|
174
|
+
if (!e && a >= this.melodyVoices()) return;
|
|
175
|
+
h = r[a] + 3, o = i[3];
|
|
176
|
+
}
|
|
177
|
+
const p = 63 - Math.floor((Math.min(127, s) + 128) * (63 - (o & 63)) / 256);
|
|
178
|
+
this.opl.write(64 + h, o & 192 | p);
|
|
179
|
+
}
|
|
180
|
+
noteOn(t, s, i = 127) {
|
|
181
|
+
if (this.applyVelocity(t, i), this.rhythm && f[t]) {
|
|
182
|
+
const h = f[t];
|
|
183
|
+
this.setPitch(l[t].ch, s, !1), this.rhythmKeys &= ~h, this.opl.write(189, 224 | this.rhythmKeys), this.rhythmKeys |= h, this.opl.write(189, 224 | this.rhythmKeys), this.notes[t] = s;
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
t >= this.melodyVoices() || (this.notes[t] >= 0 && this.opl.write(176 + t, this.opl.read(176 + t) & -33), this.setPitch(t, s, !0), this.notes[t] = s);
|
|
187
|
+
}
|
|
188
|
+
noteOff(t, s) {
|
|
189
|
+
if (this.notes[t] === s) {
|
|
190
|
+
if (this.notes[t] = -1, this.rhythm && f[t]) {
|
|
191
|
+
this.rhythmKeys &= ~f[t], this.opl.write(189, 224 | this.rhythmKeys);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
t < this.melodyVoices() && this.opl.write(176 + t, this.opl.read(176 + t) & -33);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
export {
|
|
199
|
+
n as CmfPlayer,
|
|
200
|
+
m as TICK_RATE
|
|
201
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
let e, i = !1;
|
|
2
|
+
const r = /* @__PURE__ */ new Set(), l = ["pointerup", "touchend", "click", "keydown", "mousedown"];
|
|
3
|
+
function v() {
|
|
4
|
+
if (!(typeof window > "u"))
|
|
5
|
+
return window.AudioContext ?? window.webkitAudioContext;
|
|
6
|
+
}
|
|
7
|
+
function u() {
|
|
8
|
+
try {
|
|
9
|
+
const t = navigator.audioSession;
|
|
10
|
+
t && t.type !== "playback" && (t.type = "playback");
|
|
11
|
+
} catch {
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function w() {
|
|
15
|
+
if (e) return e;
|
|
16
|
+
const t = v();
|
|
17
|
+
if (t) {
|
|
18
|
+
try {
|
|
19
|
+
u(), e = new t({ latencyHint: "interactive" });
|
|
20
|
+
} catch {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
return d(), e.addEventListener("statechange", () => {
|
|
24
|
+
e && e.state !== "running" && i && document.visibilityState === "visible" && e.resume().catch(() => {
|
|
25
|
+
});
|
|
26
|
+
}), navigator.userActivation?.isActive && s(), e;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function y() {
|
|
30
|
+
return i && !!e && e.state === "running";
|
|
31
|
+
}
|
|
32
|
+
function k(t) {
|
|
33
|
+
return r.add(t), () => r.delete(t);
|
|
34
|
+
}
|
|
35
|
+
function s() {
|
|
36
|
+
const t = w();
|
|
37
|
+
if (!t) return;
|
|
38
|
+
u();
|
|
39
|
+
const a = !i;
|
|
40
|
+
if (i = !0, t.state !== "running" && t.resume().catch(() => {
|
|
41
|
+
}), a)
|
|
42
|
+
try {
|
|
43
|
+
const n = t.createBufferSource();
|
|
44
|
+
n.buffer = t.createBuffer(1, 1, t.sampleRate), n.connect(t.destination), n.start(0);
|
|
45
|
+
} catch {
|
|
46
|
+
}
|
|
47
|
+
for (const n of [...r])
|
|
48
|
+
try {
|
|
49
|
+
n();
|
|
50
|
+
} catch (f) {
|
|
51
|
+
console.warn(f);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
let c = !1;
|
|
55
|
+
function p(t) {
|
|
56
|
+
t.type === "keydown" && (t.key === "Shift" || t.key === "Meta") || t.isTrusted && (y() || s());
|
|
57
|
+
}
|
|
58
|
+
function o() {
|
|
59
|
+
e && i && e.state !== "running" && document.visibilityState === "visible" && e.resume().catch(() => {
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
function d() {
|
|
63
|
+
if (!(c || typeof window > "u")) {
|
|
64
|
+
c = !0;
|
|
65
|
+
for (const t of l) window.addEventListener(t, p, { capture: !0, passive: !0 });
|
|
66
|
+
document.addEventListener("visibilitychange", o), window.addEventListener("pageshow", o), window.addEventListener("focus", o);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
typeof window < "u" && d();
|
|
70
|
+
export {
|
|
71
|
+
w as audioContext,
|
|
72
|
+
k as onSoundUnlocked,
|
|
73
|
+
y as soundUnlocked,
|
|
74
|
+
s as unlock
|
|
75
|
+
};
|