@moonbase-machines/player 0.9.1 → 0.11.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/play.js ADDED
@@ -0,0 +1,195 @@
1
+ // The player: one way in for every title and every way to play it, the Original (the game's own disk on the machine
2
+ // it was made for) and the Remake (its scenes on the remake's engine, or the title's own engine). The player makes its
3
+ // part of the page and owns it, the same for all: the section and its classes, the stage and its size, the options
4
+ // line and the status, the keys, the sound, the printer, the saves on the title's disk, and what the page asks of it;
5
+ // the engine plays the game on the stage and does nothing of the page's. The page tells the player everything: the
6
+ // disk and the settings are the page's to give (its provider, provider.js), and the player reads no address and
7
+ // keeps nothing in the browser itself.
8
+ //
9
+ // await play({ way, slug, title, data, root, disk, settings, preview, words, screen, watch, claim, pace, keys,
10
+ // route, mini, roms, studies, downloadDisk })
11
+ // way 'original' or 'remake' (the default)
12
+ // slug the title's name in addresses: its section's class, its disk
13
+ // title the title's folder, an absolute URL (title.js, its scenes, compiled/, its own modules), where it
14
+ // has one; a game without one (a study) has only its data
15
+ // data its game's data, an absolute URL (<name>.json: its disks' files, its kind, its saves), or for a
16
+ // title of its own engine the folder its data is in
17
+ // root the page's section the player is made in
18
+ // disk the title's disk (disk/disk.js `openDisk`, `title/<slug>`), the page's to open where it keeps files
19
+ // settings the person's settings, the page's typed store (settings.js): each declared setting's value, kept
20
+ // preview the preview's build: what only it has may be used
21
+ // words the page's words the player shows: { printer: { line, print, tear } }
22
+ // screen [width, height], the screen's shape in its own dots where the page draws its own over it
23
+ // and what a page that follows the game asks:
24
+ // watch called with { engine, press, quick } once the game is ready: its engine (where it has one), a key
25
+ // pressed as the player presses one, and the game at full speed or not
26
+ // claim k => true to take a key of the player's before the game does
27
+ // pace how much of the original's waits: 1 all (the default), 0 none, between a part of each; a number or
28
+ // a function, read at each wait
29
+ // and what only a workshop has, given by its pages, never looked for here:
30
+ // keys keys to press at the start, by name (space, return, left...), to go straight to a scene
31
+ // route a route to play first, { name, route, keys }: its keys, and the pilots' key handling
32
+ // mini a mini view for a title with an `inset`, (at, { memory }) => { mode(m) }
33
+ // roms the makers' ROMs this machine has (engines/original.js; a Commodore 64 remake's { chargen })
34
+ // studies files of its own for a Commodore 64 game's disk, in the drive, not kept
35
+ // downloadDisk a Commodore 64 game's disk offered to download, with what is saved on it
36
+ //
37
+ // An engine (engines/original.js, engines/scenes.js, or the title's own, its title.js `engine` a module in its folder)
38
+ // exports `play(game)`: given `game`, the player's own for it (below), it declares the settings it acts on
39
+ // (`game.setting(declaration)`, settings.js, each shown only so), makes what it draws on the stage and returns
40
+ // its handle, or nothing where it cannot start (and has said why in the status):
41
+ // { run(), press(k)?, key(e, k)?, keys(list)?, route(route)?, start()?, sound(on)?, quick(on)?,
42
+ // engine?, diskImage()? }
43
+ // run() starts it; press(k) a key, the machine's code (none for a title of its own engine with no machine's keys,
44
+ // which reads the keyboard itself); key(e, k), for an engine that reads the keyboard itself, each
45
+ // keydown and keyup with its code (undefined where the machine has none); keys(list), the page's `keys` at the start;
46
+ // route, the page's route; start(), at the player's first gesture (sound may start only then); sound(on), the
47
+ // player's speaker on or off (the Sound setting; a game's own sound setting stays the game's); quick(on) the game at
48
+ // full speed; engine, what `watch` gives a page; diskImage(), the disk to download.
49
+ import { titleOf, loadTitle, read } from './remake/load.js';
50
+ import { savesFor } from './saves.js';
51
+ import { code, named } from './keys.js';
52
+ import { paperBox, phoneKeys, KEYS_MARKUP } from './parts.js';
53
+
54
+ // each machine's screen, in its own dots: an Apple's hi-res page, a Commodore 64's frame, border and all
55
+ const SCREENS = { prodos: [280, 192], dos33: [280, 192], c64: [384, 272] };
56
+ const ENGINES = { original: () => import('./engines/original.js'), card: () => import('./engines/scenes.js'), vector: () => import('./engines/scenes.js') };
57
+ // the settings the player itself acts on (settings.js): the speaker; a Commodore 64's printer; a title's version with
58
+ // its bugs fixed
59
+ const SOUND = { name: 'sound', type: 'boolean', default: true, owner: 'person', words: 'Sound' };
60
+ const PRINTER = { name: 'printer', type: 'boolean', default: true, owner: 'person', words: 'Printer' };
61
+ const FIXED = { name: 'fixed', type: 'boolean', default: false, owner: 'person', words: 'Bugs fixed', title: 'The game with its bugs fixed: it starts again' };
62
+
63
+ export async function play(options) {
64
+ const { way = 'remake', slug, root, disk, settings, preview = false, words } = options;
65
+ root.classList.add('player', way);
66
+ if (slug) root.classList.add(slug);
67
+ root.innerHTML = `<div class="stage"></div><p class="options"><span id="status">Loading.</span>${KEYS_MARKUP}<span class="settings"></span>`
68
+ + '<button id="disk" type="button" hidden>Download the disk</button></p>';
69
+ root.append(paperBox(words.printer));
70
+ const $ = id => root.querySelector('#' + id);
71
+ const stage = root.querySelector('.stage'), status = $('status'), line = root.querySelector('.options .settings');
72
+ const fail = err => { status.textContent = 'The game stopped: ' + err.message; status.className = 'error'; console.error(err); };
73
+ // A setting the player or its engine acts on, by its declaration (settings.js): its value from the page's store,
74
+ // and its control on the options line where the page offers it, a box for a boolean, a menu for an enum, each
75
+ // change kept and told; `first`, before the others (the sound)
76
+ const setting = (def, { first = false } = {}) => {
77
+ const s = { value: settings.get(def), told: [], on(f) { this.told.push(f); return this; } };
78
+ if (!settings.offered(def)) return s;
79
+ const label = document.createElement('label'), choice = Array.isArray(def.type);
80
+ if (def.title) label.title = def.title;
81
+ const control = choice
82
+ ? Object.assign(document.createElement('select'), { innerHTML: def.type.map((v, i) => `<option value="${v}">${def.labels[i]}</option>`).join('') })
83
+ : Object.assign(document.createElement('input'), { type: 'checkbox' });
84
+ control.id = def.name;
85
+ if (choice) { control.value = s.value; label.append(def.words + ' ', control); } else { control.checked = s.value; label.append(control, ' ' + def.words); }
86
+ control.addEventListener('change', () => { s.value = choice ? control.value : control.checked; settings.set(def, s.value); for (const f of s.told) f(s.value); });
87
+ line[first ? 'prepend' : 'append'](label);
88
+ return s;
89
+ };
90
+ try {
91
+ if (!disk || !settings) throw new Error('the page gives the player its disk and its settings');
92
+ // the title with its bugs fixed, for one that has such a version (its title.js `fixed`), a setting: its scripts
93
+ // chosen as it loads, so a change loads the page again
94
+ const fixed = settings.get(FIXED);
95
+ const folder = options.title ? new URL(options.title) : null, data = new URL(options.data);
96
+ // the title, and for its remake its scripts; the game's data (none here for a title of its own engine, which
97
+ // reads its own from its folder)
98
+ let title = null, scripts = {}, game = null;
99
+ if (way === 'remake') ({ title, scripts, disk: game } = await loadTitle(folder, { data, fixed }));
100
+ else if (folder) title = await titleOf(folder);
101
+ if (!game && /\.json$/.test(data.pathname)) game = await read(data, 'json');
102
+ const kind = game?.kind;
103
+ // the title's own styles, where it has them (its title.js `style`), in place before it draws by them
104
+ if (title?.style && !document.querySelector(`link[data-title="${title.slug}"]`)) {
105
+ const link = Object.assign(document.createElement('link'), { rel: 'stylesheet', href: new URL(title.style, folder).href });
106
+ link.dataset.title = title.slug; document.head.append(link);
107
+ await new Promise(done => { link.onload = link.onerror = done; });
108
+ }
109
+ // (a save the disk's backend could not store, said, not lost)
110
+ disk.failed = err => { status.textContent = 'A file was not kept: ' + err.message; status.className = 'error'; };
111
+ const [dots, lines] = options.screen ?? title?.screen ?? SCREENS[kind] ?? SCREENS.prodos;
112
+ fit(stage, root, dots, lines);
113
+
114
+ const g = {
115
+ way, slug, title, scripts, game, folder, data, stage, status, fail, disk, preview, words, fixed, $, setting,
116
+ saves: game ? savesFor(game, disk) : null, roms: options.roms, studies: options.studies, mini: options.mini ?? null,
117
+ chargen: null, // (the engine's Commodore 64 characters, for the printer's paper)
118
+ pace: () => { const p = options.pace; return typeof p === 'function' ? p() : p ?? 1; },
119
+ // an engine's own part of the options line that is no setting (Aztlan's preview selector), after the settings
120
+ control(html) { line.insertAdjacentHTML('afterend', html); },
121
+ };
122
+ // a printer on device 4, for a Commodore 64 game: on unless the setting is off, what it prints drawn below the
123
+ // screen as a sheet of paper, until it is torn off
124
+ if (kind === 'c64') {
125
+ const { Printer, paper } = await import('@moonbase-machines/c64/printer.js');
126
+ const on = setting(PRINTER);
127
+ const printer = g.printer = new Printer({ on: on.value });
128
+ on.on(v => { printer.on = v; });
129
+ const sheet = $('paper'), shown = $('paper-box');
130
+ printer.changed = () => { shown.hidden = !printer.lines.length; if (printer.lines.length) paper(sheet, printer, g.chargen); };
131
+ $('tear').addEventListener('click', () => printer.tear());
132
+ $('print-paper').addEventListener('click', () => window.print()); // (the sheet alone: its print style)
133
+ }
134
+ if (way === 'remake' && title?.fixed) setting(FIXED).on(() => location.reload());
135
+
136
+ const engine = way === 'original' ? await ENGINES.original() : title.own ?? await ENGINES[title.engine]();
137
+ const handle = await engine.play(g);
138
+ if (!handle) return;
139
+
140
+ // the sound, where the engine has it: the player's speaker, on or off
141
+ if (handle.sound) { const on = setting(SOUND, { first: true }); handle.sound(on.value); on.on(v => handle.sound(v)); }
142
+ // the keys, one listener for every engine: the machine's code for each (keys.js), offered to the page first
143
+ // (`claim`), then the engine's: the event itself where it reads the keyboard, else the code pressed
144
+ const keyed = e => {
145
+ const k = code(e, kind), down = e.type === 'keydown';
146
+ if (down && k !== undefined && !e.repeat && options.claim?.(k)) { e.preventDefault(); return; }
147
+ if (down) handle.start?.();
148
+ if (handle.key) { handle.key(e, k); return; }
149
+ if (!down || k === undefined || e.repeat || !handle.press) return; // (a held key is one key)
150
+ e.preventDefault(); handle.press(k);
151
+ };
152
+ addEventListener('keydown', keyed); addEventListener('keyup', keyed);
153
+ stage.addEventListener('pointerdown', () => handle.start?.());
154
+ phoneKeys(root, stage);
155
+ // the disk as it stands, its own files and those saved, to download where the page offers it (a .d64 that VICE
156
+ // or a real 64 can use)
157
+ if (options.downloadDisk && handle.diskImage) {
158
+ const button = $('disk'), name = data.pathname.split('/').pop().replace(/\.json$/, '');
159
+ button.hidden = false;
160
+ button.addEventListener('click', () => {
161
+ const a = Object.assign(document.createElement('a'), { href: URL.createObjectURL(new Blob([handle.diskImage()], { type: 'application/octet-stream' })), download: name + '.d64' });
162
+ a.click(); URL.revokeObjectURL(a.href);
163
+ });
164
+ }
165
+ // a page that follows the game: its engine, a key pressed as the player presses one, the game at full speed
166
+ options.watch?.({ engine: handle.engine, press: k => { handle.start?.(); handle.press?.(k); }, quick: on => handle.quick?.(on) });
167
+ // a route the page gives, played first; then the game; then the page's keys, to go straight to a scene
168
+ if (options.route) {
169
+ if (!handle.route) throw new Error('a route cannot be played on the ' + way);
170
+ await handle.route(options.route);
171
+ }
172
+ handle.run();
173
+ if (options.keys?.length) { const list = options.keys.map(n => named(n, kind)); if (handle.keys) handle.keys(list); else for (const k of list) handle.press?.(k); }
174
+ } catch (err) { fail(err); }
175
+ }
176
+
177
+ // The screen sized evenly: a whole number of the device's pixels to each of the screen's dots, so every dot is drawn
178
+ // the same size and a line one dot thick is as thick as any other; the next whole number up from the column's width,
179
+ // a little wider than it and centred on it, unless that is wider than the window or taller than the stage may be (its
180
+ // max-height, where the page sets one: a phone on its side); kept so as the window, or its zoom, changes. The scale,
181
+ // in the page's pixels to a dot, is the section's --scale too, for a title drawn in CSS.
182
+ function fit(stage, column, dots, lines) {
183
+ const size = () => {
184
+ const dpr = window.devicePixelRatio || 1, pad = parseFloat(getComputedStyle(column).paddingLeft) * 2;
185
+ const room = column.clientWidth - pad, most = document.documentElement.clientWidth - 16, tall = parseFloat(getComputedStyle(stage).maxHeight);
186
+ let k = Math.ceil(room * dpr / dots - 0.01);
187
+ if (dots * k / dpr > most) k = Math.max(1, Math.floor(most * dpr / dots));
188
+ if (tall && lines * k / dpr > tall) k = Math.max(1, Math.floor(tall * dpr / lines));
189
+ const w = dots * k / dpr;
190
+ Object.assign(stage.style, { width: w + 'px', height: lines * k / dpr + 'px', aspectRatio: 'auto', marginLeft: (room - w) / 2 + 'px' });
191
+ column.style.setProperty('--scale', k / dpr);
192
+ };
193
+ size();
194
+ addEventListener('resize', size);
195
+ }
package/player.css CHANGED
@@ -3,16 +3,17 @@
3
3
  where the page names them (--screen, --muted, --gold, --alert, --line, --surface, --ink, --bg, --accent,
4
4
  --accent-ink, --sans), else these. */
5
5
 
6
- .game .stage { position: relative; width: min(100%, 960px, calc((var(--vvh, 100vh) - 260px) * 560 / 384)); margin: 0 auto; aspect-ratio: 560 / 384; background: var(--screen, #000); border-radius: 10px; overflow: hidden; box-shadow: 0 10px 30px -12px #0006; }
7
- .game .stage canvas, .game .stage svg { position: absolute; inset: 0; width: 100%; height: 100%; }
8
- .game .stage canvas { image-rendering: pixelated; }
6
+ /* (the stage's size the player's, play.js `fit`: a whole number of the device's pixels to each of the screen's dots) */
7
+ .player .stage { position: relative; margin: 0 auto; background: var(--screen, #000); border-radius: 10px; overflow: hidden; box-shadow: 0 10px 30px -12px #0006; }
8
+ .player .stage canvas, .player .stage svg { position: absolute; inset: 0; width: 100%; height: 100%; }
9
+ .player .stage canvas { image-rendering: pixelated; }
9
10
  /* the remake's mini view, where the page gives one: in the band at the top of the screen, placed by the title's inset */
10
- .game .stage .mini { position: absolute; overflow: hidden; border-radius: 3px; background: #000; pointer-events: none; }
11
- .game .stage .mini.toggle { pointer-events: auto; cursor: pointer; }
12
- .game .stage .mini canvas, .game .stage .mini img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; image-rendering: auto; }
13
- .game.remake .stage { background: #fff; }
14
- .game.remake .stage svg .key { cursor: pointer; }
15
- .game.remake .stage svg .key:hover rect { stroke: var(--gold, #a8801f); }
11
+ .player .stage .mini { position: absolute; overflow: hidden; border-radius: 3px; background: #000; pointer-events: none; }
12
+ .player .stage .mini.toggle { pointer-events: auto; cursor: pointer; }
13
+ .player .stage .mini canvas, .player .stage .mini img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; image-rendering: auto; }
14
+ .player.remake .stage { background: #fff; }
15
+ .player.remake .stage svg .key { cursor: pointer; }
16
+ .player.remake .stage svg .key:hover rect { stroke: var(--gold, #a8801f); }
16
17
  .options { display: flex; flex-wrap: wrap; gap: 8px 18px; align-items: center; justify-content: center; font-size: 15px; color: var(--muted, #5d6475); margin: 14px 0; }
17
18
  #status.error { color: var(--alert, #9b3d2e); }
18
19
  #paper { display: block; width: 100%; height: auto; border-radius: 2px; box-shadow: 0 2px 8px #0005; }
package/provider.js ADDED
@@ -0,0 +1,27 @@
1
+ // The provider: where a visit keeps a person's files and settings, chosen once, and every page's disks and settings
2
+ // from it. A site makes one a page from a module of its own, choosing the backend (this browser's; an account's, the
3
+ // site's API's, when signed in) and giving a visit's values and its policy; a page with no site (moonbase-play's) makes
4
+ // this one with nothing given. The player and the programs never choose: a page passes the title's disk and the
5
+ // settings to play() (play.js), and a program that saves opens its disks here. Nothing here knows of an address.
6
+ //
7
+ // const { disk, settings } = await provider({ backend, visit, fixed, hidden })
8
+ // backend where files are kept (disk/disk.js says what one is); none given, this browser's, IndexedDB
9
+ // (disk/local.js), or where the browser keeps nothing (a private window may not) memory, for this
10
+ // visit only, and the disks say so (`kept`)
11
+ // visit def => a setting's value for this visit, the site's to give (settings.js); fixed and hidden, the
12
+ // page's policy of the settings
13
+ // await disk(name) a disk by its name, on the backend: titleDisk(slug), programDisk(name) (disk/disk.js)
14
+ // settings the person's settings, the typed store (settings.js), on the disk settingsDisk('person')
15
+ import { openDisk } from './disk/disk.js';
16
+ import { local } from './disk/local.js';
17
+ import { memory } from './disk/memory.js';
18
+ import { settingsStore } from './settings.js';
19
+
20
+ export async function provider({ backend, visit, fixed = {}, hidden = [] } = {}) {
21
+ if (!backend) { try { backend = await local(); } catch { backend = memory({ kept: false }); } }
22
+ const disk = name => openDisk(name, { backend });
23
+ const settings = settingsStore({ disks: { person: await disk(settingsDisk('person')) }, visit, fixed, hidden });
24
+ return { disk, settings };
25
+ }
26
+
27
+ export const settingsDisk = owner => `settings/${owner}`;
package/remake/card.js CHANGED
@@ -135,8 +135,9 @@ export function cardRoutines(title) {
135
135
  else installRom({ cpu, game: title.disk, sw: { lcRead: 0 }, io: a => io(engine, a), file: n => files.file(n), get files() { return files.files; }, put: (path, f) => files.put(path, f), remove: path => files.remove(path), clicks: { push: c => engine.clicks.push(engine.cycles + c - romFrom) }, log() {}, halted: false });
136
136
  const rom = (e, at) => {
137
137
  const f = cpu.hooks.get(at);
138
- // a Commodore 64's BASIC, which MENU runs NOTEBOOK (compiled BASIC) and ZOOTIME (BASIC) with
139
- if (!f && c64 && at >= 0xa000 && at < 0xc000) throw new Error('a BASIC program (Zoopak\'s NOTEBOOK or ZOOTIME): not written yet in the remake');
138
+ // a Commodore 64's BASIC, which a BASIC program calls, as Zoopak's MENU runs NOTEBOOK (compiled BASIC) and
139
+ // ZOOTIME (BASIC) with
140
+ if (!f && c64 && at >= 0xa000 && at < 0xc000) throw new Error('a BASIC program, calling BASIC\'s ROM at $' + hex4(at) + ': not written yet in the remake');
140
141
  if (!f) throw new Error('no ROM routine at $' + hex4(at));
141
142
  engine = e;
142
143
  // (the stack pointer as the call to the routine left it: the frames' place, less the call's two bytes)
@@ -437,7 +438,7 @@ export function cardRoutines(title) {
437
438
  // as the observer has the original take it (the pilots' observer), and then the VIC's raster
438
439
  // interrupt, where the game has turned it on ($D01A), at the line it set (Find Me's split of its
439
440
  // screen into two character sets), $D019 saying which it is. On the page's clock (
440
- // scenes.js sets `e.clock`), each is taken as the original's chips took it: the CIA's every 16,421
441
+ // engines/scenes.js sets `e.clock`), each is taken as the original's chips took it: the CIA's every 16,421
441
442
  // cycles of the original's time, the raster's when its line comes (routines.before). Not while
442
443
  // the game has them masked (SEI). The routine starts as the KERNAL's own entry ($FF48) leaves
443
444
  // it, with A, X and Y pushed, and the KERNAL's end ($EA31, $EA7B, $EA7E, $EA81), or its own PLAs
package/remake/load.js CHANGED
@@ -1,8 +1,9 @@
1
- // One way to start a title's remake, for the remake's page (main.js), a replay and Node (a pilot's observer): the
1
+ // One way to start a title's remake, for the player (play.js), a replay and Node (a pilot's observer): the
2
2
  // title's title.js, from the folder it is in, with the modules it names (its problems, its paint); its scripts,
3
3
  // compiled to JavaScript (bin/moonbase-compile.mjs) or, with `scenes`, the scene files themselves to run line by line;
4
4
  // its game's data, from where the page keeps it; and an engine made from them.
5
5
  //
6
+ // const title = await titleOf(folder) the title alone: its title.js and the modules it names
6
7
  // const { title, scripts, disk, folder } = await loadTitle(folder, { data })
7
8
  // folder the title's folder, an absolute URL (its title.js, its scenes, compiled/, its own modules)
8
9
  // data its game's data, an absolute URL (<slug>.json: its disks' files, its kind, its saves)
@@ -11,7 +12,7 @@
11
12
  // With `fixed`, a title that has a version with its bugs fixed (its title.js `fixed`, a script for each it replaces)
12
13
  // runs those scripts in place of its own. A title whose engine is its own (its title.js `engine` the name of a module
13
14
  // in its folder, not 'card' or 'vector') has no scripts here: its engine is loaded with its other modules, as
14
- // `title.own`, and runs the game itself (main.js).
15
+ // `title.own`, and runs the game itself (play.js).
15
16
  import { Engine } from './engine.js';
16
17
  import { vectorRoutines } from './vector.js';
17
18
  import { cardRoutines } from './card.js';
@@ -29,13 +30,19 @@ async function read(url, as) {
29
30
  }
30
31
  const defaultOf = async url => (await import(url.href)).default;
31
32
 
32
- export async function loadTitle(folder, { data, scenes = false, fixed = false } = {}) {
33
+ export async function titleOf(folder) {
33
34
  folder = new URL(folder);
34
35
  const title = { ...(await defaultOf(new URL('title.js', folder))) };
35
36
  // the modules a title has of its own, as its title.js names them (problems.js, paint.js), and its view's own
36
37
  // drawing (view.js), where it says it has one
37
38
  for (const name of title.modules ?? []) title[name] = await defaultOf(new URL(name + '.js', folder));
38
39
  if (title.view) title.viewSteps = await defaultOf(new URL('view.js', folder));
40
+ return title;
41
+ }
42
+
43
+ export async function loadTitle(folder, { data, scenes = false, fixed = false } = {}) {
44
+ folder = new URL(folder);
45
+ const title = await titleOf(folder);
39
46
  if (!ENGINES[title.engine]) {
40
47
  title.own = await import(new URL(title.engine + '.js', folder).href);
41
48
  return { title, scripts: {}, disk: null, folder };
@@ -46,6 +53,8 @@ export async function loadTitle(folder, { data, scenes = false, fixed = false }
46
53
  return { title, scripts, disk, folder };
47
54
  }
48
55
 
56
+ export { read };
57
+
49
58
  export function makeEngine(title, scripts) {
50
59
  // (a ProDOS title's memory starts with what ProDOS leaves, as the original's does)
51
60
  const memory = { ...(title.disk?.kind === 'prodos' ? started(title.disk) : {}), ...title.memory };
package/saves.js ADDED
@@ -0,0 +1,22 @@
1
+ // What a game saves, on the player's disk, the one way for every way to play (play.js gives it to the engine as
2
+ // `game.saves`): an Apple game's volume it saves on (@moonbase-machines/apple2e/saves.js, its data's `saves`), a
3
+ // Commodore 64 game's drive (@moonbase-machines/c64/saves.js). A game saved in one way loads in the other.
4
+ //
5
+ // const saves = savesFor(data, disk)
6
+ // saves.data the game's data with what is kept put on its volume, for the machine or the engine
7
+ // saves.keep(files) the game's files watched (a machine's, an engine's, a drive's on line 1), each change kept
8
+ // saves.drive(drive) a Commodore 64's drive given what is kept, and what it saves kept
9
+ // saves.disks(disks) line 1: what is kept written onto the disk images Apple's ROMs boot
10
+ // saves.driveFiles(m, disks) line 1: the files as the drive's tracks hold them, for `keep`
11
+ import { withSaves, keepSaves, onDisks, driveFiles } from '@moonbase-machines/apple2e/saves.js';
12
+ import { keepDrive } from '@moonbase-machines/c64/saves.js';
13
+
14
+ export function savesFor(data, disk) {
15
+ return {
16
+ data: withSaves(data, disk),
17
+ keep: files => keepSaves(data, files, disk),
18
+ drive: drive => keepDrive(drive, disk),
19
+ disks: disks => onDisks(data, disks, disk),
20
+ driveFiles: (m, disks) => driveFiles(m, disks, data),
21
+ };
22
+ }
package/settings.js ADDED
@@ -0,0 +1,82 @@
1
+ // A person's settings: what the player, its engines and a site's pages let a person choose (the sound, the printer,
2
+ // the old style text, a page's own choices), each declared where it acts, in one shape, and kept through the provider
3
+ // (provider.js) on a disk of the owner's, settings/<owner>, where the person's files go. The store is typed against
4
+ // the declarations: only a declared setting can be read or kept, every value checked against its type. Nothing here
5
+ // knows of an address: a site that lets its address set a setting for a visit reads it itself and gives the value.
6
+ //
7
+ // A declaration: { name, type, default, owner, words, labels?, title? }
8
+ // name its name, in the store's file (and for a site, in whatever sets it for a visit)
9
+ // type 'boolean', or an array of its fixed values, an enum
10
+ // default its value where nothing gives one
11
+ // owner whose it is: 'person' (the only owner yet; a parent's or teacher's later is another layer here, with
12
+ // nothing changed where the setting is declared)
13
+ // words what its control calls it; labels, an enum's values' words in order; title, a note on the control
14
+ //
15
+ // check(type, value) whether a value is of the type
16
+ // describe(type) the type in words, for saying what was refused
17
+ //
18
+ // const settings = settingsStore({ disks, visit, fixed, hidden })
19
+ // disks { owner: disk }, the owner's settings disk (settings/<owner>), opened by the provider; its one file,
20
+ // `settings`, the values by name
21
+ // visit def => a value for this visit, or undefined: the site's, from wherever it lets a visit set one (its
22
+ // address, say); checked here, a value not of the type refused and said once; never kept
23
+ // fixed { name: value }, what the page fixes, over everything; hidden, [name], what it does not offer
24
+ // settings.get(def) its value: the page's fixed one, else the visit's, else the kept one where it is of
25
+ // the type (one that is not falls back, said), else its default
26
+ // settings.set(def, value) kept, for the next visit; a promise, kept when stored (a value not of the type throws)
27
+ // settings.offered(def) whether its control is shown: not where the page hides it or fixes it
28
+ import { textBytes, bytesText } from './disk/disk.js';
29
+
30
+ const FILE = 'settings';
31
+
32
+ export function check(type, value) {
33
+ if (Array.isArray(type)) return type.includes(value);
34
+ if (type === 'boolean') return typeof value === 'boolean';
35
+ throw new Error('settings: no such type: ' + JSON.stringify(type));
36
+ }
37
+
38
+ export const describe = type => Array.isArray(type) ? 'one of ' + type.map(v => v === '' ? 'nothing' : v).join(', ') : 'on or off';
39
+
40
+ export function settingsStore({ disks, visit = () => undefined, fixed = {}, hidden = [] }) {
41
+ const said = new Set();
42
+ const say = (key, what) => { if (!said.has(key)) { said.add(key); console.warn(what); } };
43
+ const layers = Object.fromEntries(Object.entries(disks).map(([owner, disk]) => {
44
+ let kept = null;
45
+ try { kept = JSON.parse(bytesText(disk.read(FILE)) ?? '{}'); } catch {}
46
+ return [owner, { disk, kept: kept && typeof kept === 'object' && !Array.isArray(kept) ? kept : {} }];
47
+ }));
48
+ // (the declaration itself checked as it is first met: its owner has a store here, its default is of its type)
49
+ const seen = new Set();
50
+ const layer = def => {
51
+ const l = layers[def.owner];
52
+ if (!l) throw new Error(`settings: no store for the owner ${def.owner} (${def.name})`);
53
+ if (!seen.has(def.name)) { seen.add(def.name); if (!check(def.type, def.default)) throw new Error(`settings: ${def.name}'s default ${JSON.stringify(def.default)} is not ${describe(def.type)}`); }
54
+ return l;
55
+ };
56
+ return {
57
+ get(def) {
58
+ const l = layer(def);
59
+ if (def.name in fixed) {
60
+ if (!check(def.type, fixed[def.name])) throw new Error(`settings: ${def.name} fixed at ${JSON.stringify(fixed[def.name])}, not ${describe(def.type)}`);
61
+ return fixed[def.name];
62
+ }
63
+ const v = visit(def);
64
+ if (v !== undefined) {
65
+ if (check(def.type, v)) return v;
66
+ say('visit ' + def.name, `${def.name}: ${JSON.stringify(v)} for this visit refused: not ${describe(def.type)}`);
67
+ }
68
+ if (def.name in l.kept) {
69
+ if (check(def.type, l.kept[def.name])) return l.kept[def.name];
70
+ say('kept ' + def.name, `${def.name}: the kept ${JSON.stringify(l.kept[def.name])} is not ${describe(def.type)}; its default used`);
71
+ }
72
+ return def.default;
73
+ },
74
+ set(def, value) {
75
+ const l = layer(def);
76
+ if (!check(def.type, value)) throw new Error(`settings: ${def.name} cannot be ${JSON.stringify(value)}: not ${describe(def.type)}`);
77
+ l.kept[def.name] = value;
78
+ return l.disk.write(FILE, textBytes(JSON.stringify(l.kept)));
79
+ },
80
+ offered: def => !hidden.includes(def.name) && !(def.name in fixed),
81
+ };
82
+ }
@@ -0,0 +1,152 @@
1
+ // The player's parts that every way to play shares (play.js): the keys as each machine takes them (keys.js), the
2
+ // saves on the player's disk (saves.js), a write the disk's backend cannot store (disk/disk.js); the settings store
3
+ // typed against its declarations (settings.js) and the provider giving a page its disks and settings on one backend
4
+ // (provider.js); the package knowing nothing of an address and keeping nothing in the browser itself; and no
5
+ // title named in the packages' code, only in their comments, as examples.
6
+ //
7
+ // node --test test/*.test.js
8
+ import { test } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { code, named } from '@moonbase-machines/player/keys.js';
13
+ import { savesFor } from '@moonbase-machines/player/saves.js';
14
+ import { openDisk } from '@moonbase-machines/player/disk/disk.js';
15
+ import { memory } from '@moonbase-machines/player/disk/memory.js';
16
+ import { settingsStore, check } from '@moonbase-machines/player/settings.js';
17
+ import { provider, settingsDisk } from '@moonbase-machines/player/provider.js';
18
+ import { Drive } from '@moonbase-machines/c64/drive.js';
19
+ import { make } from '@moonbase-machines/c64/d64.js';
20
+
21
+ const key = (k, mods = {}) => ({ key: k, ctrlKey: false, metaKey: false, altKey: false, ...mods });
22
+
23
+ test('a key is the Apple\'s code or the Commodore 64\'s, by the machine\'s kind', () => {
24
+ assert.equal(code(key('ArrowLeft'), 'prodos'), 8);
25
+ assert.equal(code(key('ArrowLeft'), 'c64'), 157);
26
+ assert.equal(code(key('Tab'), 'dos33'), 9);
27
+ assert.equal(code(key('a'), 'prodos'), 97, 'the Apple a small letter as typed');
28
+ assert.equal(code(key('a'), 'c64'), 65, 'the 64 its capital');
29
+ assert.equal(code(key('e', { ctrlKey: true }), 'prodos'), 5, 'control-E');
30
+ assert.equal(code(key('a', { metaKey: true }), 'prodos'), undefined, 'the browser\'s own');
31
+ assert.equal(code(key('a'), undefined), undefined, 'a title of its own engine has no table');
32
+ assert.equal(code(key('Shift'), 'prodos'), undefined);
33
+ assert.deepEqual(['space', 'left', 'A'].map(n => named(n, 'prodos')), [32, 8, 65]);
34
+ assert.deepEqual(['left', 'delete'].map(n => named(n, 'c64')), [157, 20]);
35
+ });
36
+
37
+ test('the saves: a game\'s volume given what is kept, and a change to its files kept', async () => {
38
+ const disk = await openDisk('title/one', { backend: memory() });
39
+ disk.write('KEPT', [1, 2], { type: 'BIN', aux: 0x2000 });
40
+ const game = { kind: 'prodos', volumes: { GAME: {} }, saves: { volume: 'SAVES', dos: 'prodos' } };
41
+ const saves = savesFor(game, disk);
42
+ assert.deepEqual(Object.keys(saves.data.volumes), ['GAME', 'SAVES']);
43
+ assert.equal(saves.data.volumes.SAVES.KEPT.type, 'BIN');
44
+ // a Commodore 64's drive: what it saves is kept on the disk
45
+ const drive = new Drive(make({ name: 'DISK', id: 'MB', files: [] }));
46
+ savesFor({ kind: 'c64' }, disk).drive(drive);
47
+ drive.keep('SAVED', Uint8Array.of(1, 8, 9));
48
+ await disk.written();
49
+ assert.deepEqual([...disk.read('SAVED')], [1, 8, 9]);
50
+ });
51
+
52
+ test('a write the backend cannot store is said, and the writes after it are stored', async () => {
53
+ const backend = memory(), put = backend.put;
54
+ let refuse = true;
55
+ backend.put = (d, f) => refuse && f.name === 'BAD' ? Promise.reject(new Error('full')) : put.call(backend, d, f);
56
+ const disk = await openDisk('title/two', { backend }), said = [];
57
+ disk.failed = err => said.push(err.message);
58
+ await assert.rejects(disk.write('BAD', [1]), /full/);
59
+ await disk.write('GOOD', [2]);
60
+ await disk.written();
61
+ assert.deepEqual(said, ['full']);
62
+ refuse = false;
63
+ assert.deepEqual([...(await openDisk('title/two', { backend })).read('GOOD')], [2]);
64
+ });
65
+
66
+ // (what the console is told, for the tests of what is refused)
67
+ const warned = async f => { const said = [], warn = console.warn; console.warn = m => said.push(m); try { await f(); } finally { console.warn = warn; } return said; };
68
+
69
+ test('a setting\'s type: on or off, or one of fixed values', () => {
70
+ assert.ok(check('boolean', false) && !check('boolean', 'off') && check(['a', 'b'], 'a') && !check(['a', 'b'], 'c'));
71
+ assert.throws(() => check('text', 'x'), /no such type/);
72
+ });
73
+
74
+ const SOUND = { name: 'sound', type: 'boolean', default: true, owner: 'person', words: 'Sound' };
75
+ const MINI = { name: 'mini', type: ['', 'live', 'pictures'], default: '', owner: 'person', words: 'Mini view', labels: ['Off', 'Live', 'Pictures'] };
76
+ const OLD = { name: 'old-style', type: 'boolean', default: false, owner: 'person', words: 'Old style text' };
77
+ const FIXED = { name: 'fixed', type: 'boolean', default: false, owner: 'person', words: 'Bugs fixed' };
78
+
79
+ test('a setting by its declaration: the page\'s fixed value, else the visit\'s, else the kept one, else its default; offered unless hidden or fixed', async () => {
80
+ const backend = memory(), person = () => openDisk(settingsDisk('person'), { backend });
81
+ const first = settingsStore({ disks: { person: await person() } });
82
+ assert.equal(first.get(SOUND), true, 'its default');
83
+ await first.set(SOUND, false); await first.set(MINI, 'pictures');
84
+ const visit = def => ({ 'old-style': true, mini: 'live', sound: false })[def.name];
85
+ const again = settingsStore({ disks: { person: await person() }, visit, fixed: { fixed: true }, hidden: ['mini'] });
86
+ assert.equal(again.get(SOUND), false, 'the visit\'s, and kept');
87
+ assert.equal(again.get(MINI), 'live', 'the visit\'s over the kept');
88
+ assert.equal(again.get(OLD), true, 'the visit\'s on');
89
+ assert.equal(again.get(FIXED), true, 'the page\'s fixed value over all');
90
+ assert.deepEqual([SOUND, MINI, FIXED].map(again.offered), [true, false, false]);
91
+ assert.deepEqual(JSON.parse(new TextDecoder().decode((await person()).read('settings'))), { sound: false, mini: 'pictures' }, 'the visit\'s values never kept');
92
+ });
93
+
94
+ test('a setting\'s value is checked: a visit\'s value not of the type refused and said once, a kept value not of the type its default, a set of the wrong type thrown', async () => {
95
+ const backend = memory(), disk = await openDisk(settingsDisk('person'), { backend });
96
+ disk.write('settings', new TextEncoder().encode(JSON.stringify({ sound: 'loud', mini: 'live' })));
97
+ const store = settingsStore({ disks: { person: disk }, visit: def => ({ mini: 'huge', 'old-style': 'maybe' })[def.name] });
98
+ const said = await warned(() => { assert.equal(store.get(SOUND), true, 'the kept value refused: its default'); assert.equal(store.get(MINI), 'live', 'huge refused: the kept'); store.get(MINI); assert.equal(store.get(OLD), false); });
99
+ assert.deepEqual(said, ['sound: the kept "loud" is not on or off; its default used', 'mini: "huge" for this visit refused: not one of nothing, live, pictures', 'old-style: "maybe" for this visit refused: not on or off']);
100
+ assert.throws(() => store.set(SOUND, 'off'), /sound cannot be "off": not on or off/);
101
+ assert.throws(() => store.set(MINI, 'big'), /not one of nothing, live, pictures/);
102
+ assert.throws(() => store.get({ ...SOUND, owner: 'teacher' }), /no store for the owner teacher/);
103
+ assert.throws(() => store.get({ name: 'bad', type: 'boolean', default: 'on', owner: 'person' }), /default "on" is not on or off/);
104
+ assert.throws(() => settingsStore({ disks: { person: disk }, fixed: { sound: 'off' } }).get(SOUND), /fixed at "off"/);
105
+ });
106
+
107
+ test('the provider: every disk on the one backend it is given, and the settings kept there too', async () => {
108
+ const backend = memory();
109
+ const { disk, settings } = await provider({ backend, visit: def => def.name === 'sound' ? false : undefined, hidden: ['vectors'] });
110
+ const title = await disk('title/one');
111
+ await title.write('SAVED', [1]);
112
+ await settings.set(MINI, 'live');
113
+ assert.deepEqual((await backend.load('title/one')).map(f => f.name), ['SAVED']);
114
+ assert.deepEqual((await backend.load('settings/person')).map(f => f.name), ['settings']);
115
+ assert.equal(settings.get(SOUND), false);
116
+ assert.equal(settings.offered({ name: 'vectors', type: 'boolean', default: false, owner: 'person' }), false);
117
+ });
118
+
119
+ // the package's own code knows nothing of an address and keeps nothing in the browser: the page gives it everything
120
+ // (bin/moonbase-play.mjs is a page of its own, the one place an address is read, by that page)
121
+ test('the player, its engines, its settings and its provider know no address and no browser storage; the player opens no disk', () => {
122
+ const own = ['play.js', 'settings.js', 'provider.js', 'engines/original.js', 'engines/scenes.js', 'saves.js', 'keys.js', 'parts.js', 'disk/disk.js', 'disk/dialogs.js'];
123
+ const found = [];
124
+ for (const f of own) {
125
+ const lines = readFileSync(join(packages, 'player', f), 'utf8').replace(/\/\*[\s\S]*?\*\//g, c => c.replace(/[^\n]/g, '')).split('\n');
126
+ lines.forEach((l, i) => {
127
+ const c = l.replace(/(^|\s)\/\/.*$/, '');
128
+ if (/location\.(search|hash|href)|URLSearchParams|localStorage|sessionStorage/.test(c)) found.push(`${f}:${i + 1}`);
129
+ if (/openDisk/.test(c) && !['provider.js', 'disk/disk.js'].includes(f)) found.push(`${f}:${i + 1} opens a disk`);
130
+ if (/\blocal\(/.test(c) && f !== 'provider.js') found.push(`${f}:${i + 1} chooses a backend`);
131
+ });
132
+ }
133
+ assert.deepEqual(found, []);
134
+ });
135
+
136
+ // the titles, by name and by address; the packages' code, their comments taken out
137
+ const TITLES = /pieces[ -]of[ -]eight|detect-a-pet|goldfields|zoopak|nature[ -]park|animal[ -]rescue|aztlan|crayon|po8/i;
138
+ const packages = new URL('../../', import.meta.url).pathname;
139
+ const sources = dir => readdirSync(dir).flatMap(n => {
140
+ const f = join(dir, n);
141
+ if (['node_modules', 'dist', 'test'].includes(n)) return [];
142
+ return statSync(f).isDirectory() ? sources(f) : /\.m?js$/.test(n) ? [f] : [];
143
+ });
144
+
145
+ test('no title is named in the packages\' code, only in their comments', () => {
146
+ const found = [];
147
+ for (const f of sources(packages)) {
148
+ const lines = readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, c => c.replace(/[^\n]/g, '')).split('\n');
149
+ lines.forEach((l, i) => { const c = l.replace(/(^|\s)\/\/.*$/, ''); if (TITLES.test(c)) found.push(`${f.slice(packages.length)}:${i + 1}`); });
150
+ }
151
+ assert.deepEqual(found, []);
152
+ });