@moonbase-machines/player 0.0.0-stage → 0.9.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/LICENSE +21 -0
- package/README.md +40 -2
- package/audio.js +54 -0
- package/bin/moonbase-compile.mjs +23 -0
- package/bin/moonbase-play.mjs +81 -0
- package/data.js +39 -0
- package/disk/dialogs.js +101 -0
- package/disk/disk.js +57 -0
- package/disk/local.js +28 -0
- package/disk/memory.js +13 -0
- package/dist/moonbase-player.js +33 -0
- package/dist/moonbase-player.js.map +7 -0
- package/index.js +28 -0
- package/modal.js +68 -0
- package/original.js +200 -0
- package/package.json +25 -4
- package/parts.js +33 -0
- package/player.css +48 -0
- package/remake/c64-view.js +30 -0
- package/remake/card-view.js +53 -0
- package/remake/card.js +526 -0
- package/remake/cards.js +47 -0
- package/remake/compile.js +179 -0
- package/remake/engine.js +461 -0
- package/remake/events.js +79 -0
- package/remake/layout.js +144 -0
- package/remake/lift.js +197 -0
- package/remake/load.js +55 -0
- package/remake/main.js +51 -0
- package/remake/problems.js +43 -0
- package/remake/raster.js +72 -0
- package/remake/scenes.js +243 -0
- package/remake/script.js +60 -0
- package/remake/vector.js +363 -0
- package/remake/view.js +479 -0
- package/test/disk.test.js +65 -0
- package/test/load.test.js +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mark Wharton
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,41 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @moonbase-machines/player
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The player for Mark Wharton's titles of 1985 to 1995, the one way each is played in a page, in two ways to play:
|
|
4
|
+
|
|
5
|
+
- **Original** (`original.js`): the game's own disk, untouched, on Moonbase's Apple IIe or Commodore 64
|
|
6
|
+
(`@moonbase-machines/apple2e`, `c64`), on Moonbase's own ROMs, which need nothing of Apple's or Commodore's.
|
|
7
|
+
- **Remake** (`remake/main.js`): the game's scenes, lifted from its code and compiled to JavaScript, on an engine:
|
|
8
|
+
the remake's own for a title of scenes (`card` or `vector`), or the title's own, a module in its folder. Either
|
|
9
|
+
way the player makes the page's part, the stage, the options line and the status; the title brings its engine
|
|
10
|
+
and its view.
|
|
11
|
+
|
|
12
|
+
What a player saves, a game saved, goes on **the player's disk** (`disk/`): a title's own disk of files in this
|
|
13
|
+
browser (IndexedDB), with its own dialogs (`disk/dialogs.js`, in a modal window, `modal.js`). A game saved in one
|
|
14
|
+
way loads in the other.
|
|
15
|
+
|
|
16
|
+
No title is named in it. A title is a folder of its own, kept with the title, which the page gives the player:
|
|
17
|
+
|
|
18
|
+
- `title.js`, what the title is: its engine (`'card'`, `'vector'` or a module of its own), its scripts and the one
|
|
19
|
+
it starts with, its `map` (the routines its scenes call, by address, and what its engine needs beside them:
|
|
20
|
+
`remake/cards.js` and `remake/vector.js` say what each engine reads), the modules it brings (`modules`, its
|
|
21
|
+
problems; `view`, its view's own drawing), its font's own glyphs (`font`), its stylesheet (`style`);
|
|
22
|
+
- its scenes (`<name>.scene`) and `compiled/` (`moonbase-compile`).
|
|
23
|
+
|
|
24
|
+
Beside it the game's data, `<name>.json` (its disks' files, its kind, and where it saves: `saves`).
|
|
25
|
+
|
|
26
|
+
```html
|
|
27
|
+
<link rel="stylesheet" href="/packages/player/player.css">
|
|
28
|
+
<script type="importmap">{"imports": {"@moonbase-machines/player/": "/packages/player/", "@moonbase-machines/apple2e/": "/packages/apple2e/", "@moonbase-machines/c64/": "/packages/c64/", "@moonbase-machines/charset/": "/packages/charset/", "@moonbase-machines/mos6502/": "/packages/mos6502/", "@moonbase-machines/vector/": "/packages/vector/"}}</script>
|
|
29
|
+
<section id="player"></section>
|
|
30
|
+
<script type="module">
|
|
31
|
+
import { play } from '@moonbase-machines/player/remake/main.js';
|
|
32
|
+
import { openDisk } from '@moonbase-machines/player/disk/disk.js';
|
|
33
|
+
await play({ title: new URL('title/', location).href, data: new URL('game.json', location).href,
|
|
34
|
+
root: document.getElementById('player'), disk: await openDisk('title/game'), words: { printer: { line: '', print: 'Print', tear: 'Tear off' } } });
|
|
35
|
+
</script>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`moonbase-compile <title folder>` compiles a title's scenes; `moonbase-play [folder] [port]` serves a title's page
|
|
39
|
+
on this machine (http://127.0.0.1:8002/), the packages from `node_modules`.
|
|
40
|
+
|
|
41
|
+
Part of Moonbase (moonbase.au). MIT; see LICENSE. Each file opens with what it is and what it covers.
|
package/audio.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// The speaker. The Apple II makes sound by clicking the speaker, a toggle at $C030, and the
|
|
2
|
+
// machine keeps the cycle of each click. `Speaker` turns a run of cycles into a square wave
|
|
3
|
+
// at the sound card's rate and plays it through a worklet, which keeps a queue of what it has
|
|
4
|
+
// been given and holds the last level when the queue runs dry.
|
|
5
|
+
const CYCLES_PER_SECOND = 1020484;
|
|
6
|
+
|
|
7
|
+
const WORKLET = `
|
|
8
|
+
class SpeakerProcessor extends AudioWorkletProcessor {
|
|
9
|
+
constructor() { super(); this.queue = []; this.at = 0; this.level = 0; this.port.onmessage = e => this.queue.push(e.data); }
|
|
10
|
+
process(inputs, outputs) {
|
|
11
|
+
const out = outputs[0][0];
|
|
12
|
+
for (let i = 0; i < out.length; i++) {
|
|
13
|
+
while (this.queue.length && this.at >= this.queue[0].length) { this.queue.shift(); this.at = 0; }
|
|
14
|
+
if (this.queue.length) this.level = this.queue[0][this.at++];
|
|
15
|
+
out[i] = this.level;
|
|
16
|
+
}
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
registerProcessor('speaker', SpeakerProcessor);
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
export class Speaker {
|
|
24
|
+
constructor() { this.context = null; this.node = null; this.level = 0; this.from = null; }
|
|
25
|
+
|
|
26
|
+
// start on a gesture, as browsers require
|
|
27
|
+
async start() {
|
|
28
|
+
if (this.context) return;
|
|
29
|
+
this.context = new AudioContext();
|
|
30
|
+
await this.context.audioWorklet.addModule(URL.createObjectURL(new Blob([WORKLET], { type: 'text/javascript' })));
|
|
31
|
+
this.node = new AudioWorkletNode(this.context, 'speaker');
|
|
32
|
+
this.node.connect(this.context.destination);
|
|
33
|
+
this.rate = this.context.sampleRate;
|
|
34
|
+
if (this.context.state !== 'running') await this.context.resume();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// the wave for the cycles from the last call up to `cycles`, given the clicks in between
|
|
38
|
+
feed(clicks, cycles) {
|
|
39
|
+
if (!this.node) return;
|
|
40
|
+
if (this.from === null) this.from = cycles; // from when the sound starts, not from the machine's first cycle
|
|
41
|
+
const perSample = CYCLES_PER_SECOND / this.rate;
|
|
42
|
+
const n = Math.floor((cycles - this.from) / perSample);
|
|
43
|
+
if (n <= 0) return;
|
|
44
|
+
const wave = new Float32Array(n);
|
|
45
|
+
let ci = 0;
|
|
46
|
+
for (let i = 0; i < n; i++) {
|
|
47
|
+
const t = this.from + (i + 1) * perSample;
|
|
48
|
+
while (ci < clicks.length && clicks[ci] <= t) { this.level = this.level ? 0 : 0.25; ci++; }
|
|
49
|
+
wave[i] = this.level;
|
|
50
|
+
}
|
|
51
|
+
this.from += n * perSample;
|
|
52
|
+
this.node.port.postMessage(wave, [wave.buffer]);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// A title's scene scripts compiled to JavaScript (remake/compile.js): each <name>.scene in the title's folder written
|
|
3
|
+
// as compiled/<name>.js beside it, which the remake runs.
|
|
4
|
+
//
|
|
5
|
+
// moonbase-compile <title folder> ... every scene of each
|
|
6
|
+
// moonbase-compile --check <title folder> ... says which compiled script is not its scene's, and fails
|
|
7
|
+
import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync } from 'node:fs';
|
|
8
|
+
import { resolve } from 'node:path';
|
|
9
|
+
import { pathToFileURL } from 'node:url';
|
|
10
|
+
import { compile } from '../remake/compile.js';
|
|
11
|
+
|
|
12
|
+
const args = process.argv.slice(2), check = args.includes('--check'), folders = args.filter(a => a !== '--check');
|
|
13
|
+
if (!folders.length) { console.error('moonbase-compile [--check] <title folder> ...'); process.exit(1); }
|
|
14
|
+
let stale = 0, written = 0;
|
|
15
|
+
for (const folder of folders) {
|
|
16
|
+
const dir = pathToFileURL(resolve(folder) + '/'), io = (await import(new URL('title.js', dir))).default.io;
|
|
17
|
+
for (const f of readdirSync(dir).filter(f => f.endsWith('.scene'))) {
|
|
18
|
+
const name = f.slice(0, -6), js = compile(readFileSync(new URL(f, dir), 'utf8'), name, io ? { io } : undefined), out = new URL('compiled/' + name + '.js', dir);
|
|
19
|
+
if (existsSync(out) && readFileSync(out, 'utf8') === js) continue;
|
|
20
|
+
if (check) { console.log(`${folder}/compiled/${name}.js is not compiled from ${f}`); stale++; } else { mkdirSync(new URL('compiled/', dir), { recursive: true }); writeFileSync(out, js); written++; }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (check) { if (stale) process.exit(1); } else console.log(`${written} compiled`);
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// A title played on this machine: its page at http://127.0.0.1:8002/, its Remake where it has one, and an Original for
|
|
3
|
+
// each game's data beside it (/?way=original, or with more than one /?game=<name>), on the browser's own player's
|
|
4
|
+
// disk; each file sent with `Cache-Control: no-cache` so that a refresh asks for every module again. The folder holds
|
|
5
|
+
// the title's folder, title/ (title.js, its scenes, compiled/), if it is remade, and its games' data, <name>.json
|
|
6
|
+
// each, or for a title of its own engine data/; the @moonbase-machines packages are served from node_modules at
|
|
7
|
+
// /packages/, where the page's import map finds them. A folder with an index.html of its own is served as it is.
|
|
8
|
+
//
|
|
9
|
+
// moonbase-play [folder] [port] [host] the folder (play/ unless given)
|
|
10
|
+
import { createServer } from 'node:http';
|
|
11
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
12
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
13
|
+
import { extname, join, normalize, resolve, sep } from 'node:path';
|
|
14
|
+
import { pathToFileURL } from 'node:url';
|
|
15
|
+
|
|
16
|
+
const [folder = 'play', port = 8002, host = '127.0.0.1'] = process.argv.slice(2);
|
|
17
|
+
const root = resolve(folder) + sep, packages = resolve('node_modules/@moonbase-machines') + sep;
|
|
18
|
+
// (what a browser shows rather than downloads; anything else, a disk image among them, as bytes)
|
|
19
|
+
const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.json': 'application/json', '.css': 'text/css',
|
|
20
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.ico': 'image/x-icon',
|
|
21
|
+
'.pdf': 'application/pdf', '.txt': 'text/plain; charset=utf-8', '.wav': 'audio/wav', '.scene': 'text/plain; charset=utf-8' };
|
|
22
|
+
const PACKAGES = ['mos6502', 'charset', 'c64', 'apple2e', 'vector', 'player'];
|
|
23
|
+
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
24
|
+
|
|
25
|
+
// the page: the title's name, its ways, the player
|
|
26
|
+
async function page() {
|
|
27
|
+
const remade = existsSync(join(root, 'title', 'title.js'));
|
|
28
|
+
const title = remade ? (await import(pathToFileURL(join(root, 'title', 'title.js')).href)).default : null;
|
|
29
|
+
// (its games: each <name>.json beside the title, its data, and the title's own first)
|
|
30
|
+
const games = await Promise.all(readdirSync(root).filter(f => f.endsWith('.json') && f !== 'package.json').sort((a, b) => (b === title?.slug + '.json') - (a === title?.slug + '.json') || a.slice(0, -5).localeCompare(b.slice(0, -5)))
|
|
31
|
+
.map(async f => ({ name: f.slice(0, -5), data: f, game: JSON.parse(await readFile(join(root, f), 'utf8')) })));
|
|
32
|
+
const slug = title?.slug ?? games[0]?.name, name = title?.name ?? games[0]?.game.title ?? slug;
|
|
33
|
+
// (the ways: the remake, its data the title's own or, for a title of its own engine, data/; an original for each game)
|
|
34
|
+
const ways = [...(title ? [{ label: 'Remake', search: '', module: 'remake/main.js', data: existsSync(join(root, slug + '.json')) ? slug + '.json' : 'data/' }] : []),
|
|
35
|
+
...games.map((g, i) => ({ label: games.length > 1 ? g.game.title : 'Original', search: !title && !i ? '' : games.length > 1 ? `?game=${g.name}` : '?way=original', module: 'original.js', data: g.data }))];
|
|
36
|
+
const map = { imports: Object.fromEntries(PACKAGES.map(p => [`@moonbase-machines/${p}/`, `/packages/${p}/`])) };
|
|
37
|
+
return `<!doctype html>
|
|
38
|
+
<html lang="en">
|
|
39
|
+
<head>
|
|
40
|
+
<meta charset="utf-8">
|
|
41
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
42
|
+
<title>${esc(name)}</title>
|
|
43
|
+
<link rel="stylesheet" href="/packages/player/player.css">
|
|
44
|
+
<style>body { margin: 0; font: 17px/1.6 system-ui, sans-serif; color: #1b2233; background: #fbf9f4; } main { max-width: 1000px; margin: 0 auto; padding: 16px; } h1 { font-size: 28px; margin: 0 0 8px; } nav a { margin-right: 14px; } nav a[aria-current] { font-weight: 600; color: inherit; text-decoration: none; }</style>
|
|
45
|
+
<script type="importmap">${JSON.stringify(map)}</script>
|
|
46
|
+
</head>
|
|
47
|
+
<body>
|
|
48
|
+
<main>
|
|
49
|
+
<h1>${esc(name)}</h1>
|
|
50
|
+
${ways.length > 1 ? `<nav>${ways.map(w => `<a href="/${w.search}">${esc(w.label)}</a>`).join('')}</nav>` : ''}
|
|
51
|
+
<section id="player"></section>
|
|
52
|
+
</main>
|
|
53
|
+
<script type="module">
|
|
54
|
+
const ways = ${JSON.stringify(ways)};
|
|
55
|
+
const way = ways.find(w => w.search === location.search) ?? ways[0];
|
|
56
|
+
for (const a of document.querySelectorAll('nav a')) if (a.search === way.search) a.setAttribute('aria-current', 'page');
|
|
57
|
+
const [{ play }, { openDisk, titleDisk }] = await Promise.all([import('@moonbase-machines/player/' + way.module), import('@moonbase-machines/player/disk/disk.js')]);
|
|
58
|
+
const at = path => new URL(path, location.origin + '/').href;
|
|
59
|
+
await play({ title: at('title/'), data: at(way.data), root: document.getElementById('player'), disk: await openDisk(titleDisk(${JSON.stringify(slug)})),
|
|
60
|
+
words: { printer: { line: 'What the printer has printed.', print: 'Print', tear: 'Tear off' } } });
|
|
61
|
+
</script>
|
|
62
|
+
</body>
|
|
63
|
+
</html>
|
|
64
|
+
`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
createServer(async (req, res) => {
|
|
68
|
+
const path = normalize(decodeURIComponent(new URL(req.url, 'http://x').pathname));
|
|
69
|
+
try {
|
|
70
|
+
if ((path === sep || path === sep + 'index.html') && !existsSync(join(root, 'index.html'))) {
|
|
71
|
+
res.writeHead(200, { 'Content-Type': TYPES['.html'], 'Cache-Control': 'no-cache' }).end(await page());
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
let file = path.startsWith(sep + 'packages' + sep) ? join(packages, path.slice(10)) : join(root, path);
|
|
75
|
+
if (![root, packages].some(r => file.startsWith(r))) { res.writeHead(403).end(); return; }
|
|
76
|
+
if ((await stat(file)).isDirectory()) file = join(file, 'index.html');
|
|
77
|
+
const body = await readFile(file);
|
|
78
|
+
res.writeHead(200, { 'Content-Type': TYPES[extname(file).toLowerCase()] ?? 'application/octet-stream', 'Cache-Control': 'no-cache' });
|
|
79
|
+
res.end(body);
|
|
80
|
+
} catch (err) { res.writeHead(404, { 'Content-Type': 'text/plain' }).end('not found: ' + path + (err.code === 'ENOENT' ? '' : '\n' + err.message)); }
|
|
81
|
+
}).listen(+port, host, () => console.log(`http://${host}:${port}/`));
|
package/data.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// A game's data as the player wants it, in Node: every file of each volume, base64, with its type and load address;
|
|
2
|
+
// a ProDOS game names its system file, a DOS 3.3 game the files to load and where and the address to call, as its
|
|
3
|
+
// BASIC program does, a C64 game the name to LOAD; where it saves, `saves`. An Apple game's disks are given back
|
|
4
|
+
// whole as well, for a machine with Apple's own ROMs, which boots them (`images`, @moonbase-machines/apple2e/disk2.js,
|
|
5
|
+
// blocks.js), the data naming them.
|
|
6
|
+
//
|
|
7
|
+
// const { data, images } = gameData(slug, { title, kind, disks, system, call, loads, wait, start, saves })
|
|
8
|
+
// kind 'prodos', 'dos33' or 'c64'
|
|
9
|
+
// disks each disk image's bytes, in order (a 140K .dsk, a .2mg; a C64's .d64)
|
|
10
|
+
// saves where the game saves, { volume, dos } (@moonbase-machines/apple2e/saves.js), if it does
|
|
11
|
+
// data the game's data, for <slug>.json
|
|
12
|
+
// images an Apple game's disks, [{ name: <slug>-<n>.dsk or .2mg, bytes }]
|
|
13
|
+
import { prodos } from '@moonbase-machines/apple2e/prodos-disk.js';
|
|
14
|
+
import { dos33 } from '@moonbase-machines/apple2e/dos33-disk.js';
|
|
15
|
+
import { read as d64 } from '@moonbase-machines/c64/d64.js';
|
|
16
|
+
|
|
17
|
+
const b64 = bytes => Buffer.from(bytes).toString('base64');
|
|
18
|
+
|
|
19
|
+
export function gameData(slug, game) {
|
|
20
|
+
const data = { title: game.title, kind: game.kind, volumes: {}, system: game.system, call: game.call, loads: game.loads, wait: game.wait, start: game.start, images: undefined };
|
|
21
|
+
const images = game.kind === 'c64' ? [] : game.disks.map((bytes, n) => ({ name: slug + '-' + (n + 1) + (bytes.length === 143360 ? '.dsk' : '.2mg'), bytes }));
|
|
22
|
+
if (images.length) data.images = images.map(i => i.name);
|
|
23
|
+
for (const image of game.disks) {
|
|
24
|
+
if (game.kind === 'prodos') {
|
|
25
|
+
const disk = prodos(image), files = {};
|
|
26
|
+
for (const f of disk.files) if (f.typeName !== 'DIR') files[f.path] = { type: f.typeName, aux: f.aux, size: f.size, data: b64(f.body) };
|
|
27
|
+
data.volumes[disk.volume] = files;
|
|
28
|
+
} else if (game.kind === 'c64') {
|
|
29
|
+
d64(image); // checked, and the whole image kept, since the drive in the browser reads it by the sector
|
|
30
|
+
data.image = b64(image);
|
|
31
|
+
} else {
|
|
32
|
+
const disk = dos33(image), files = {};
|
|
33
|
+
for (const f of disk.files) files[f.name] = { type: f.type, aux: f.address || 0, size: f.body.length, data: b64(f.body) };
|
|
34
|
+
data.volumes['DISK'] = files;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (game.saves) data.saves = game.saves;
|
|
38
|
+
return { data, images };
|
|
39
|
+
}
|
package/disk/dialogs.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// The disk's own dialogs, the same in every game that hands saving to the system (as Aztlan for Macintosh
|
|
2
|
+
// handed it to the Mac's): a game is saved by name, and opened from the disk's list, or from a file
|
|
3
|
+
// downloaded before. A game that asks on its own screen (the 8-bit games) keeps its own prompt; the disk
|
|
4
|
+
// only keeps what it saves. Each is a modal window (../modal.js) opened where the game says, its host: in
|
|
5
|
+
// its screen, in the frame its own windows have (Aztlan's, window definition 1, the Mac's dialog box), and
|
|
6
|
+
// so looking as its own windows look; with no host, on the page, as the site's (site/assets/site.css).
|
|
7
|
+
//
|
|
8
|
+
// const { saveAs, openFrom, ask, tell } = dialogs({ within, className }) the game's host
|
|
9
|
+
// await saveAs(disk, { suggested }) a name, or null: offers to replace a file of that name
|
|
10
|
+
// await openFrom(disk, { file }) { name, bytes } from the disk, or null: the disk's manager, the
|
|
11
|
+
// same for every game, its games listed, each to open, download
|
|
12
|
+
// or delete, and a downloaded one put back on it from a file
|
|
13
|
+
// (`file`, the game's own: { extension, type }, '.zip' and its type)
|
|
14
|
+
// await ask(heading, body, buttons, { aside }) a dialog of a game's own: [label, value] buttons, the
|
|
15
|
+
// last the default, a function's value what it returns; `aside`,
|
|
16
|
+
// buttons of the dialog's own on the same line, at its left
|
|
17
|
+
// await tell(heading) a word, and OK
|
|
18
|
+
import { modal } from '../modal.js';
|
|
19
|
+
|
|
20
|
+
export function dialogs({ within, className = '' } = {}) {
|
|
21
|
+
function ask(heading, body, buttons, { aside = [] } = {}) {
|
|
22
|
+
return new Promise(done => {
|
|
23
|
+
const content = document.createElement('div'); content.className = 'disk';
|
|
24
|
+
const h = document.createElement('h2'); h.textContent = heading;
|
|
25
|
+
const row = document.createElement('p'); row.className = 'buttons';
|
|
26
|
+
for (const [label, value] of buttons) {
|
|
27
|
+
const b = Object.assign(document.createElement('button'), { type: 'button', textContent: label });
|
|
28
|
+
b.onclick = async () => { m.close(); done(typeof value === 'function' ? await value() : value); };
|
|
29
|
+
row.append(b);
|
|
30
|
+
}
|
|
31
|
+
row.lastChild?.classList.add('default');
|
|
32
|
+
if (aside.length) { const left = document.createElement('span'); left.className = 'aside'; left.append(...aside); row.prepend(left); }
|
|
33
|
+
content.append(h, ...body, row);
|
|
34
|
+
// (Escape: nothing chosen)
|
|
35
|
+
const m = modal({ within, className, content, cancel: () => { m.close(); done(null); } });
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
const tell = heading => ask(heading, [], [['OK', null]]);
|
|
39
|
+
|
|
40
|
+
async function saveAs(disk, { suggested = '' } = {}) {
|
|
41
|
+
const input = Object.assign(document.createElement('input'), { value: suggested, maxLength: 31 });
|
|
42
|
+
setTimeout(() => input.select());
|
|
43
|
+
const label = document.createElement('label'); label.append('Save as ', input);
|
|
44
|
+
const name = await ask('Save', [label, ...keptNote(disk)], [['Cancel', null], ['Save', () => input.value.trim() || null]]);
|
|
45
|
+
if (!name) return null;
|
|
46
|
+
if (disk.read(name) && !await ask(`Replace "${name}"?`, [], [['Cancel', false], ['Replace', true]])) return null;
|
|
47
|
+
return name;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function openFrom(disk, { file: { extension = '', type = 'application/octet-stream' } = {} } = {}) {
|
|
51
|
+
const select = Object.assign(document.createElement('select'), { size: 6 });
|
|
52
|
+
const note = document.createElement('p'); note.className = 'note';
|
|
53
|
+
const input = Object.assign(document.createElement('input'), { type: 'file', accept: [extension, type].filter(Boolean).join(','), hidden: true });
|
|
54
|
+
const button = text => Object.assign(document.createElement('button'), { type: 'button', textContent: text });
|
|
55
|
+
const from = button('File…'), download = button('Download'), remove = button('Delete');
|
|
56
|
+
const fill = () => {
|
|
57
|
+
select.replaceChildren(...disk.list().map(f => new Option(`${f.name} · ${new Date(f.modified).toLocaleString()}`, f.name)));
|
|
58
|
+
select.selectedIndex = select.options.length ? 0 : -1;
|
|
59
|
+
select.hidden = !select.options.length;
|
|
60
|
+
note.textContent = select.options.length ? 'On your disk:' : 'Nothing saved on your disk yet.';
|
|
61
|
+
remove.disabled = download.disabled = !select.options.length;
|
|
62
|
+
};
|
|
63
|
+
fill();
|
|
64
|
+
const chosen = ask('Open', [note, select, input, ...keptNote(disk)], [['Cancel', null], ['Open', () => select.value || null]], { aside: [from, download, remove] });
|
|
65
|
+
const open = () => select.closest('.modal').querySelector('.default').click();
|
|
66
|
+
// (File…: a game downloaded before, put back on the disk by its file's name, and opened)
|
|
67
|
+
from.onclick = () => input.click();
|
|
68
|
+
input.onchange = async () => {
|
|
69
|
+
const f = input.files[0], n = f.name.slice(0, f.name.length - (extension && f.name.endsWith(extension) ? extension.length : 0));
|
|
70
|
+
if (disk.read(n) && !await ask(`Replace "${n}"?`, [], [['Cancel', false], ['Replace', true]])) return;
|
|
71
|
+
disk.write(n, new Uint8Array(await f.arrayBuffer()));
|
|
72
|
+
fill(); select.value = n; open();
|
|
73
|
+
};
|
|
74
|
+
// (a game taken off the disk as its file, to keep, or for the original: Aztlan's for Infinite Mac)
|
|
75
|
+
download.onclick = () => {
|
|
76
|
+
const n = select.value; if (!n) return;
|
|
77
|
+
const a = Object.assign(document.createElement('a'), { href: URL.createObjectURL(new Blob([disk.read(n)], { type })), download: n + extension });
|
|
78
|
+
a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
|
79
|
+
};
|
|
80
|
+
// (a file deleted only once asked, in a dialog over this one, Cancel its default)
|
|
81
|
+
remove.onclick = async () => {
|
|
82
|
+
const n = select.value; if (!n) return;
|
|
83
|
+
if (!await ask(`Delete "${n}"?`, [warning('It is gone from your disk for good.')], [['Delete', true], ['Cancel', false]])) return;
|
|
84
|
+
await disk.remove(n); fill(); select.focus();
|
|
85
|
+
};
|
|
86
|
+
select.ondblclick = open;
|
|
87
|
+
const name = await chosen;
|
|
88
|
+
return name ? { name, bytes: disk.read(name) } : null;
|
|
89
|
+
}
|
|
90
|
+
return { saveAs, openFrom, ask, tell };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const warning = text => Object.assign(document.createElement('p'), { className: 'note', textContent: text });
|
|
94
|
+
|
|
95
|
+
// (where the browser keeps nothing, a word saying so: the game is there until the page closes)
|
|
96
|
+
function keptNote(disk) {
|
|
97
|
+
if (disk.kept) return [];
|
|
98
|
+
const p = document.createElement('p'); p.className = 'note';
|
|
99
|
+
p.textContent = 'This browser is not keeping files: download a game to keep it.';
|
|
100
|
+
return [p];
|
|
101
|
+
}
|
package/disk/disk.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The player's disk: the files a person makes, kept by name on a disk of their own, one for each title
|
|
2
|
+
// and one for each program that saves (Microsoft BASIC's tape and disk, the editors). Every game and
|
|
3
|
+
// program that saves does so here, and only here; its files are its own bytes, in its own format, with
|
|
4
|
+
// what the format needs beside them (`meta`, a ProDOS file's type and aux).
|
|
5
|
+
//
|
|
6
|
+
// A disk is read whole when it is opened, before the program starts, and kept in memory: the machines
|
|
7
|
+
// read files at once, inside an instruction (@moonbase-machines/apple2e/machine.js's MLI call,
|
|
8
|
+
// @moonbase-machines/c64/drive.js's find), so a read is never waited for. A write changes the copy at once and is stored behind
|
|
9
|
+
// it, in order.
|
|
10
|
+
//
|
|
11
|
+
// const disk = await openDisk(name, { backend }) backend: local() (local.js, the default)
|
|
12
|
+
// or memory() (memory.js)
|
|
13
|
+
// disk.list() [{ name, size, modified, meta }], newest first
|
|
14
|
+
// disk.read(name) its bytes, or null
|
|
15
|
+
// disk.write(name, bytes, meta) a promise, kept when stored
|
|
16
|
+
// disk.remove(name) a promise
|
|
17
|
+
// disk.written() a promise: every write so far stored
|
|
18
|
+
// disk.name, disk.kept the disk's name; whether its files outlast the page
|
|
19
|
+
//
|
|
20
|
+
// A disk's name says whose it is, so a title and a program can never share one: titleDisk(slug), a title's
|
|
21
|
+
// player's, 'title/zoopak'; programDisk(name), one of the site's own programs', 'program/basic'.
|
|
22
|
+
//
|
|
23
|
+
// The backend is where a disk's files are kept: { kept, load(disk), put(disk, file), delete(disk, name) },
|
|
24
|
+
// a file { name, bytes, meta, modified }. This browser's is local(); an account's will be the API's.
|
|
25
|
+
import { local } from './local.js';
|
|
26
|
+
import { memory } from './memory.js';
|
|
27
|
+
|
|
28
|
+
export async function openDisk(name, { backend } = {}) {
|
|
29
|
+
// (a browser that keeps nothing, as a private window may: the disk in memory, and said so)
|
|
30
|
+
if (!backend) { try { backend = await local(); } catch { backend = memory({ kept: false }); } }
|
|
31
|
+
const files = new Map((await backend.load(name)).map(f => [f.name, f]));
|
|
32
|
+
let queue = Promise.resolve();
|
|
33
|
+
const later = job => (queue = queue.then(job));
|
|
34
|
+
return {
|
|
35
|
+
name,
|
|
36
|
+
kept: backend.kept,
|
|
37
|
+
list: () => [...files.values()].sort((a, b) => b.modified - a.modified).map(({ name, bytes, meta, modified }) => ({ name, size: bytes.length, modified, meta })),
|
|
38
|
+
read: n => files.get(n)?.bytes ?? null,
|
|
39
|
+
write(n, bytes, meta = {}) {
|
|
40
|
+
const f = { name: n, bytes: Uint8Array.from(bytes), meta, modified: Date.now() };
|
|
41
|
+
files.set(n, f);
|
|
42
|
+
return later(() => backend.put(name, f));
|
|
43
|
+
},
|
|
44
|
+
remove(n) {
|
|
45
|
+
files.delete(n);
|
|
46
|
+
return later(() => backend.delete(name, n));
|
|
47
|
+
},
|
|
48
|
+
written: () => queue,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const titleDisk = slug => `title/${slug}`;
|
|
53
|
+
export const programDisk = name => `program/${name}`;
|
|
54
|
+
|
|
55
|
+
// a text file's bytes and back, for the programs whose files are text (UTF-8)
|
|
56
|
+
export const textBytes = s => new TextEncoder().encode(s);
|
|
57
|
+
export const bytesText = b => b ? new TextDecoder().decode(b) : null;
|
package/disk/local.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// A disk's files kept in this browser, in IndexedDB: the database `moonbase`, its store `files`, each file
|
|
2
|
+
// keyed by its account, its disk and its name. The account is `local`, this browser's own, until there
|
|
3
|
+
// are accounts; theirs will be kept by the API, behind the same calls.
|
|
4
|
+
//
|
|
5
|
+
// await local({ account }) a backend: { kept, load(disk), put(disk, file), delete(disk, name) }
|
|
6
|
+
// (named here once, for a tool that puts a file on a disk too)
|
|
7
|
+
export const DB = 'moonbase', VERSION = 1, STORE = 'files', KEY = ['account', 'disk', 'name'], ACCOUNT = 'local';
|
|
8
|
+
|
|
9
|
+
const opened = () => new Promise((done, fail) => {
|
|
10
|
+
const r = indexedDB.open(DB, VERSION);
|
|
11
|
+
r.onupgradeneeded = () => r.result.createObjectStore(STORE, { keyPath: KEY });
|
|
12
|
+
r.onsuccess = () => done(r.result);
|
|
13
|
+
r.onerror = () => fail(r.error);
|
|
14
|
+
});
|
|
15
|
+
const asked = r => new Promise((done, fail) => { r.onsuccess = () => done(r.result); r.onerror = () => fail(r.error); });
|
|
16
|
+
|
|
17
|
+
export async function local({ account = ACCOUNT } = {}) {
|
|
18
|
+
const db = await opened();
|
|
19
|
+
const store = mode => db.transaction(STORE, mode).objectStore(STORE);
|
|
20
|
+
return {
|
|
21
|
+
kept: true,
|
|
22
|
+
// (the disk's files: those keyed from [account, disk] up to the next disk's)
|
|
23
|
+
load: async disk => (await asked(store('readonly').getAll(IDBKeyRange.bound([account, disk], [account, disk, []]))))
|
|
24
|
+
.map(({ name, bytes, meta, modified }) => ({ name, bytes, meta, modified })),
|
|
25
|
+
put: (disk, f) => asked(store('readwrite').put({ account, disk, ...f })),
|
|
26
|
+
delete: (disk, name) => asked(store('readwrite').delete([account, disk, name])),
|
|
27
|
+
};
|
|
28
|
+
}
|
package/disk/memory.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// A disk's files kept in memory only, for as long as the page: for the tests, which run where there is no
|
|
2
|
+
// IndexedDB, and for a browser that will keep nothing (disk.js says which, `kept`).
|
|
3
|
+
//
|
|
4
|
+
// memory({ kept }) a backend: { kept, load(disk), put(disk, file), delete(disk, name) }
|
|
5
|
+
export function memory({ kept = true } = {}) {
|
|
6
|
+
const disks = new Map(), of = d => disks.get(d) ?? disks.set(d, new Map()).get(d);
|
|
7
|
+
return {
|
|
8
|
+
kept,
|
|
9
|
+
load: async d => [...of(d).values()].map(f => ({ ...f, bytes: Uint8Array.from(f.bytes) })),
|
|
10
|
+
put: async (d, f) => { of(d).set(f.name, { ...f, bytes: Uint8Array.from(f.bytes) }); },
|
|
11
|
+
delete: async (d, n) => { of(d).delete(n); },
|
|
12
|
+
};
|
|
13
|
+
}
|