@moonbase-machines/player 0.9.0 → 0.10.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/README.md +23 -20
- package/bin/moonbase-play.mjs +4 -4
- package/disk/disk.js +9 -4
- package/disk/local.js +2 -2
- package/dist/moonbase-player.js +17 -18
- package/dist/moonbase-player.js.map +4 -4
- package/engines/original.js +153 -0
- package/engines/scenes.js +215 -0
- package/index.js +8 -5
- package/keys.js +28 -0
- package/package.json +6 -6
- package/play.js +186 -0
- package/player.css +10 -9
- package/remake/card.js +24 -7
- package/remake/engine.js +21 -8
- package/remake/load.js +12 -3
- package/saves.js +22 -0
- package/test/play.test.js +78 -0
- package/original.js +0 -200
- package/remake/main.js +0 -51
- package/remake/scenes.js +0 -243
package/play.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
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 player'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.
|
|
6
|
+
//
|
|
7
|
+
// await play({ way, slug, title, data, root, disk, backend, preview, words, screen, watch, claim, pace, route, mini,
|
|
8
|
+
// roms, studies, downloadDisk })
|
|
9
|
+
// way 'original' or 'remake' (the default)
|
|
10
|
+
// slug the title's name in addresses: its section's class, its disk
|
|
11
|
+
// title the title's folder, an absolute URL (title.js, its scenes, compiled/, its own modules), where it
|
|
12
|
+
// has one; a game without one (a study) has only its data
|
|
13
|
+
// data its game's data, an absolute URL (<name>.json: its disks' files, its kind, its saves), or for a
|
|
14
|
+
// title of its own engine the folder its data is in
|
|
15
|
+
// root the page's section the player is made in
|
|
16
|
+
// disk the title's player's disk (disk/disk.js); else the player opens it, `title/<slug>`, on `backend`
|
|
17
|
+
// where the page gives one (the browser's own, unless it does)
|
|
18
|
+
// preview the preview's build: what only it has may be used
|
|
19
|
+
// words the page's words the player shows: { printer: { line, print, tear } }
|
|
20
|
+
// screen [width, height], the screen's shape in its own dots where the page draws its own over it
|
|
21
|
+
// and what a page that follows the game asks:
|
|
22
|
+
// watch called with { engine, press, quick } once the game is ready: its engine (where it has one), a key
|
|
23
|
+
// pressed as the player presses one, and the game at full speed or not
|
|
24
|
+
// claim k => true to take a key of the player's before the game does
|
|
25
|
+
// pace how much of the original's waits: 1 all (the default), 0 none, between a part of each; a number or
|
|
26
|
+
// a function, read at each wait
|
|
27
|
+
// and what only a workshop has, given by its pages, never looked for here:
|
|
28
|
+
// route a route to play first, { name, route, keys }: its keys, and the pilots' key handling
|
|
29
|
+
// mini a mini view for a title with an `inset`, (at, { memory }) => { mode(m) }
|
|
30
|
+
// roms the makers' ROMs this machine has (engines/original.js; a Commodore 64 remake's { chargen })
|
|
31
|
+
// studies files of its own for a Commodore 64 game's disk, in the drive, not kept
|
|
32
|
+
// downloadDisk a Commodore 64 game's disk offered to download, with what is saved on it
|
|
33
|
+
//
|
|
34
|
+
// An engine (engines/original.js, engines/scenes.js, or the title's own, its title.js `engine` a module in its folder)
|
|
35
|
+
// exports `play(game)`: given `game`, the player's own for it (below), it makes what it draws on the stage and returns
|
|
36
|
+
// its handle, or nothing where it cannot start (and has said why in the status):
|
|
37
|
+
// { run(), press(k)?, key(e, k)?, keys(list)?, route(route)?, start()?, sound(on)?, soundOn?, quick(on)?,
|
|
38
|
+
// engine?, diskImage()? }
|
|
39
|
+
// run() starts it; press(k) a key, the machine's code (none for a title of its own engine with no machine's keys,
|
|
40
|
+
// which reads the keyboard itself); key(e, k), for an engine that reads the keyboard itself, each
|
|
41
|
+
// keydown and keyup with its code (undefined where the machine has none); keys(list), ?keys= at the start; route, the
|
|
42
|
+
// page's route; start(), at the player's first gesture (sound may start only then); sound(on) and soundOn, the
|
|
43
|
+
// Sound box; quick(on) the game at full speed; engine, what `watch` gives a page; diskImage(), the disk to download.
|
|
44
|
+
import { titleOf, loadTitle, read } from './remake/load.js';
|
|
45
|
+
import { openDisk, titleDisk } from './disk/disk.js';
|
|
46
|
+
import { savesFor } from './saves.js';
|
|
47
|
+
import { code, named } from './keys.js';
|
|
48
|
+
import { paperBox, phoneKeys, KEYS_MARKUP } from './parts.js';
|
|
49
|
+
|
|
50
|
+
// each machine's screen, in its own dots: an Apple's hi-res page, a Commodore 64's frame, border and all
|
|
51
|
+
const SCREENS = { prodos: [280, 192], dos33: [280, 192], c64: [384, 272] };
|
|
52
|
+
const ENGINES = { original: () => import('./engines/original.js'), card: () => import('./engines/scenes.js'), vector: () => import('./engines/scenes.js') };
|
|
53
|
+
|
|
54
|
+
export async function play(options) {
|
|
55
|
+
const { way = 'remake', slug, root, preview = false, words } = options;
|
|
56
|
+
root.classList.add('player', way);
|
|
57
|
+
if (slug) root.classList.add(slug);
|
|
58
|
+
root.innerHTML = `<div class="stage"></div><p class="options"><span id="status">Loading.</span>${KEYS_MARKUP}`
|
|
59
|
+
+ '<label hidden><input type="checkbox" id="sound" checked> Sound</label>'
|
|
60
|
+
+ '<label hidden><input type="checkbox" id="printer" checked> Printer</label>'
|
|
61
|
+
+ '<label hidden title="The game with its bugs fixed: it starts again"><input type="checkbox" id="fixed"> Bugs fixed</label>'
|
|
62
|
+
+ '<label hidden>Mini view <select id="mini"><option value="">Off</option><option value="live">Live</option><option value="pictures">Pictures</option></select></label>'
|
|
63
|
+
+ '<button id="disk" type="button" hidden>Download the disk</button></p>';
|
|
64
|
+
root.append(paperBox(words.printer));
|
|
65
|
+
const $ = id => root.querySelector('#' + id);
|
|
66
|
+
const stage = root.querySelector('.stage'), status = $('status'), soundBox = $('sound');
|
|
67
|
+
const fail = err => { status.textContent = 'The game stopped: ' + err.message; status.className = 'error'; console.error(err); };
|
|
68
|
+
try {
|
|
69
|
+
// ?fixed: the title with its bugs fixed, for one that has such a version (its title.js `fixed`)
|
|
70
|
+
const fixed = new URLSearchParams(location.search).has('fixed');
|
|
71
|
+
const folder = options.title ? new URL(options.title) : null, data = new URL(options.data);
|
|
72
|
+
// the title, and for its remake its scripts; the game's data (none here for a title of its own engine, which
|
|
73
|
+
// reads its own from its folder)
|
|
74
|
+
let title = null, scripts = {}, game = null;
|
|
75
|
+
if (way === 'remake') ({ title, scripts, disk: game } = await loadTitle(folder, { data, fixed }));
|
|
76
|
+
else if (folder) title = await titleOf(folder);
|
|
77
|
+
if (!game && /\.json$/.test(data.pathname)) game = await read(data, 'json');
|
|
78
|
+
const kind = game?.kind;
|
|
79
|
+
// the title's own styles, where it has them (its title.js `style`), in place before it draws by them
|
|
80
|
+
if (title?.style && !document.querySelector(`link[data-title="${title.slug}"]`)) {
|
|
81
|
+
const link = Object.assign(document.createElement('link'), { rel: 'stylesheet', href: new URL(title.style, folder).href });
|
|
82
|
+
link.dataset.title = title.slug; document.head.append(link);
|
|
83
|
+
await new Promise(done => { link.onload = link.onerror = done; });
|
|
84
|
+
}
|
|
85
|
+
const disk = options.disk ?? await openDisk(titleDisk(slug), { backend: options.backend });
|
|
86
|
+
// (a save the disk's backend could not store, said, not lost)
|
|
87
|
+
disk.failed = err => { status.textContent = 'A file was not kept: ' + err.message; status.className = 'error'; };
|
|
88
|
+
const [dots, lines] = options.screen ?? title?.screen ?? SCREENS[kind] ?? SCREENS.prodos;
|
|
89
|
+
fit(stage, root, dots, lines);
|
|
90
|
+
|
|
91
|
+
// a printer on device 4, for a Commodore 64 game: on unless its box is off (the choice kept in this browser),
|
|
92
|
+
// what it prints drawn below the screen as a sheet of paper, until it is torn off
|
|
93
|
+
let printer = null;
|
|
94
|
+
const g = {
|
|
95
|
+
way, slug, title, scripts, game, folder, data, stage, status, fail, disk, preview, words, fixed, $,
|
|
96
|
+
saves: game ? savesFor(game, disk) : null, roms: options.roms, studies: options.studies, mini: options.mini ?? null,
|
|
97
|
+
chargen: null, // (the engine's Commodore 64 characters, for the printer's paper)
|
|
98
|
+
pace: () => { const p = options.pace; return typeof p === 'function' ? p() : p ?? 1; },
|
|
99
|
+
// an engine's own control on the options line, after the sound
|
|
100
|
+
control(html) { $('printer').parentElement.insertAdjacentHTML('beforebegin', html); },
|
|
101
|
+
// the engine's sound turned on or off by the game itself (Aztlan's Game > Sounds): the box with it
|
|
102
|
+
sounding(on) { soundBox.checked = on; },
|
|
103
|
+
};
|
|
104
|
+
if (kind === 'c64') {
|
|
105
|
+
const { Printer, paper } = await import('@moonbase-machines/c64/printer.js');
|
|
106
|
+
const box = $('printer'), sheet = $('paper'), shown = $('paper-box');
|
|
107
|
+
try { box.checked = localStorage.getItem('printer') !== '0'; } catch {}
|
|
108
|
+
box.parentElement.hidden = false;
|
|
109
|
+
printer = g.printer = new Printer({ on: box.checked });
|
|
110
|
+
box.addEventListener('change', () => { printer.on = box.checked; try { localStorage.setItem('printer', box.checked ? '1' : '0'); } catch {} });
|
|
111
|
+
printer.changed = () => { shown.hidden = !printer.lines.length; if (printer.lines.length) paper(sheet, printer, g.chargen); };
|
|
112
|
+
$('tear').addEventListener('click', () => printer.tear());
|
|
113
|
+
$('print-paper').addEventListener('click', () => window.print()); // (the sheet alone: its print style)
|
|
114
|
+
}
|
|
115
|
+
// the choice of the fixed version, where there is one: the page again, with ?fixed or without
|
|
116
|
+
if (way === 'remake' && title?.fixed) {
|
|
117
|
+
const box = $('fixed');
|
|
118
|
+
box.checked = fixed; box.parentElement.hidden = false;
|
|
119
|
+
box.addEventListener('change', () => { const u = new URL(location.href); if (box.checked) u.searchParams.set('fixed', ''); else u.searchParams.delete('fixed'); location.href = u.href.replace('fixed=&', 'fixed&').replace(/fixed=$/, 'fixed'); });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const engine = way === 'original' ? await ENGINES.original() : title.own ?? await ENGINES[title.engine]();
|
|
123
|
+
const handle = await engine.play(g);
|
|
124
|
+
if (!handle) return;
|
|
125
|
+
|
|
126
|
+
// the sound, where the engine has it: the box as the engine starts it, and the engine told of each change
|
|
127
|
+
if (handle.sound) {
|
|
128
|
+
soundBox.parentElement.hidden = false; soundBox.checked = handle.soundOn ?? true;
|
|
129
|
+
soundBox.addEventListener('change', () => handle.sound(soundBox.checked));
|
|
130
|
+
}
|
|
131
|
+
// the keys, one listener for every engine: the machine's code for each (keys.js), offered to the page first
|
|
132
|
+
// (`claim`), then the engine's: the event itself where it reads the keyboard, else the code pressed
|
|
133
|
+
const keyed = e => {
|
|
134
|
+
const k = code(e, kind), down = e.type === 'keydown';
|
|
135
|
+
if (down && k !== undefined && !e.repeat && options.claim?.(k)) { e.preventDefault(); return; }
|
|
136
|
+
if (down) handle.start?.();
|
|
137
|
+
if (handle.key) { handle.key(e, k); return; }
|
|
138
|
+
if (!down || k === undefined || e.repeat || !handle.press) return; // (a held key is one key)
|
|
139
|
+
e.preventDefault(); handle.press(k);
|
|
140
|
+
};
|
|
141
|
+
addEventListener('keydown', keyed); addEventListener('keyup', keyed);
|
|
142
|
+
stage.addEventListener('pointerdown', () => handle.start?.());
|
|
143
|
+
phoneKeys(root, stage);
|
|
144
|
+
// the disk as it stands, its own files and those saved, to download where the page offers it (a .d64 that VICE
|
|
145
|
+
// or a real 64 can use)
|
|
146
|
+
if (options.downloadDisk && handle.diskImage) {
|
|
147
|
+
const button = $('disk'), name = data.pathname.split('/').pop().replace(/\.json$/, '');
|
|
148
|
+
button.hidden = false;
|
|
149
|
+
button.addEventListener('click', () => {
|
|
150
|
+
const a = Object.assign(document.createElement('a'), { href: URL.createObjectURL(new Blob([handle.diskImage()], { type: 'application/octet-stream' })), download: name + '.d64' });
|
|
151
|
+
a.click(); URL.revokeObjectURL(a.href);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
// a page that follows the game: its engine, a key pressed as the player presses one, the game at full speed
|
|
155
|
+
options.watch?.({ engine: handle.engine, press: k => { handle.start?.(); handle.press?.(k); }, quick: on => handle.quick?.(on) });
|
|
156
|
+
// a route the page gives, played first; then the game; then ?keys=space,space,right,return, to go straight to a
|
|
157
|
+
// scene
|
|
158
|
+
if (options.route) {
|
|
159
|
+
if (!handle.route) throw new Error('a route cannot be played on the ' + way);
|
|
160
|
+
await handle.route(options.route);
|
|
161
|
+
}
|
|
162
|
+
handle.run();
|
|
163
|
+
const start = new URLSearchParams(location.search).get('keys');
|
|
164
|
+
if (start) { const list = start.split(',').map(n => named(n, kind)); if (handle.keys) handle.keys(list); else for (const k of list) handle.press?.(k); }
|
|
165
|
+
} catch (err) { fail(err); }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// The screen sized evenly: a whole number of the device's pixels to each of the screen's dots, so every dot is drawn
|
|
169
|
+
// 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,
|
|
170
|
+
// a little wider than it and centred on it, unless that is wider than the window or taller than the stage may be (its
|
|
171
|
+
// max-height, where the page sets one: a phone on its side); kept so as the window, or its zoom, changes. The scale,
|
|
172
|
+
// in the page's pixels to a dot, is the section's --scale too, for a title drawn in CSS.
|
|
173
|
+
function fit(stage, column, dots, lines) {
|
|
174
|
+
const size = () => {
|
|
175
|
+
const dpr = window.devicePixelRatio || 1, pad = parseFloat(getComputedStyle(column).paddingLeft) * 2;
|
|
176
|
+
const room = column.clientWidth - pad, most = document.documentElement.clientWidth - 16, tall = parseFloat(getComputedStyle(stage).maxHeight);
|
|
177
|
+
let k = Math.ceil(room * dpr / dots - 0.01);
|
|
178
|
+
if (dots * k / dpr > most) k = Math.max(1, Math.floor(most * dpr / dots));
|
|
179
|
+
if (tall && lines * k / dpr > tall) k = Math.max(1, Math.floor(tall * dpr / lines));
|
|
180
|
+
const w = dots * k / dpr;
|
|
181
|
+
Object.assign(stage.style, { width: w + 'px', height: lines * k / dpr + 'px', aspectRatio: 'auto', marginLeft: (room - w) / 2 + 'px' });
|
|
182
|
+
column.style.setProperty('--scale', k / dpr);
|
|
183
|
+
};
|
|
184
|
+
size();
|
|
185
|
+
addEventListener('resize', size);
|
|
186
|
+
}
|
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
|
-
|
|
7
|
-
.
|
|
8
|
-
.
|
|
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
|
-
.
|
|
11
|
-
.
|
|
12
|
-
.
|
|
13
|
-
.
|
|
14
|
-
.
|
|
15
|
-
.
|
|
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/remake/card.js
CHANGED
|
@@ -135,11 +135,13 @@ 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
|
|
139
|
-
|
|
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)
|
|
144
|
+
Object.assign(cpu, { a: e.a, x: e.x, y: e.y, c: e.c, z: e.z, n: e.n, v: e.v, pc: at, sp: e.sp() - 2 & 0xff });
|
|
143
145
|
// a routine that reads the keyboard (RDKEY) waits, with no key, and the step runs again
|
|
144
146
|
const start = romFrom = cpu.cycles;
|
|
145
147
|
const r = f(cpu);
|
|
@@ -150,6 +152,9 @@ export function cardRoutines(title) {
|
|
|
150
152
|
e.delay += (cpu.cycles - start + 6) / CLOCK * 1000; e.cycles += cpu.cycles - start + 6;
|
|
151
153
|
Object.assign(e, { a: cpu.a & 0xff, x: cpu.x & 0xff, y: cpu.y & 0xff, c: cpu.c, z: cpu.z, n: cpu.n });
|
|
152
154
|
if (r === 'return') return r; // (a routine that returns from its caller too: $E206 at a statement's end)
|
|
155
|
+
// a routine that goes on elsewhere, not back: DOS 3.3's error exit, from a command printed through COUT, to where
|
|
156
|
+
// DOS goes on (rom.js `dosError`)
|
|
157
|
+
if (r === false && cpu.pc !== at) return { to: cpu.pc };
|
|
153
158
|
};
|
|
154
159
|
|
|
155
160
|
// Where an address of the lifted code is: a label in a script. The scenes all run at $AD00,
|
|
@@ -199,10 +204,11 @@ export function cardRoutines(title) {
|
|
|
199
204
|
return [name, 'L' + hex4(at)];
|
|
200
205
|
};
|
|
201
206
|
// an address the ROM's routine answers: above $D000, unless the card is read there and a script
|
|
202
|
-
// has its code there (Pieces of Eight's PO8:BLINK, over HPOSN and DRAW)
|
|
207
|
+
// has its code there (Pieces of Eight's PO8:BLINK, over HPOSN and DRAW), or below where rom.js answers DOS's own
|
|
208
|
+
// (INITIOHK, $A851) and no script has code; on a Commodore 64, where
|
|
203
209
|
// the 6510's port shows the KERNAL ($E000 on) or BASIC ($A000 to $BFFF: Tell-A-Tale's numbers,
|
|
204
210
|
// over MENU's code in the RAM under it)
|
|
205
|
-
const romAt = (e, a) => c64 ? (a >= 0xe000 ? (e.mem[1] & 2) !== 0 : a >= 0xa000 && a < 0xc000 ? (e.mem[1] & 3) === 3 : a === 0x73 || a === 0x79) : a >= 0xd000
|
|
211
|
+
const romAt = (e, a) => c64 ? (a >= 0xe000 ? (e.mem[1] & 2) !== 0 : a >= 0xa000 && a < 0xc000 ? (e.mem[1] & 3) === 3 : a === 0x73 || a === 0x79) : a >= 0xd000 ? !(lc.read && labelled(e, a).length) : cpu.hooks.has(a) && !labelled(e, a).length;
|
|
206
212
|
// the stir of START.SYSTEM's wait: its calls are no steps
|
|
207
213
|
const stirring = step => toolkit.stir && step?.at >= toolkit.stir.from && step.at <= toolkit.stir.to;
|
|
208
214
|
const emit = (e, ev, quiet) => { if (quiet) e.inside++; e.emit(ev); if (quiet) e.inside--; };
|
|
@@ -235,7 +241,16 @@ export function cardRoutines(title) {
|
|
|
235
241
|
inputs(e, name, args); emit(e, unplaced(describe(name, [], e, undefined, undefined, a => e.mem[a])), stirring(step));
|
|
236
242
|
const v = c64 && VECTORED[name], to = v && (e.mem[v[0]] | e.mem[v[0] + 1] << 8);
|
|
237
243
|
if (v && to !== v[1]) { const [script, label] = find(e, to); e.callInside(script, label, x => { if (GIVES[name]) x.emit(result(name, { a: x.a, x: x.x, y: x.y, c: x.c })); }, step?.at == null ? undefined : (step.at + 2) & 0xffff); return; }
|
|
238
|
-
|
|
244
|
+
const r = rom(e, at);
|
|
245
|
+
if (r === 'wait') return 'wait';
|
|
246
|
+
// (gone on elsewhere: the code there run inside the step, the routine's return still on the stack, as the
|
|
247
|
+
// original's is, until that code drops it, as Pieces of Eight's handler ($93F0) does with a TXS to the frames
|
|
248
|
+
// under it, kept at their place for it (Engine.stackPage))
|
|
249
|
+
if (r?.to !== undefined) {
|
|
250
|
+
e.stackPage();
|
|
251
|
+
e.stack.push({ name: e.name, pc: e.pc, inside: true, ret: step?.at == null ? undefined : (step.at + 2) & 0xffff }); e.inside++;
|
|
252
|
+
return routines['goto-engine'](e, ['$' + hex4(r.to)], step);
|
|
253
|
+
}
|
|
239
254
|
if (GIVES[name] && c64) e.emit(result(name, { a: e.a, x: e.x, y: e.y, c: e.c }));
|
|
240
255
|
};
|
|
241
256
|
|
|
@@ -301,6 +316,8 @@ export function cardRoutines(title) {
|
|
|
301
316
|
if (c64 && ENDS.has(a)) pullRegisters(e); // the end of an interrupt: the registers back, then its RTI
|
|
302
317
|
back(e); return; // as its RTS: out of a routine it ends
|
|
303
318
|
}
|
|
319
|
+
// (code the game has written since, a JMP: on through it, as Pieces of Eight's at $A6D8, into DOS's error exit)
|
|
320
|
+
if (!c64 && e.mem[a] === 0x4c && !labelled(e, a).length) return routines['goto-engine'](e, ['$' + hex4(e.mem[a + 1] | e.mem[a + 2] << 8)], step);
|
|
304
321
|
e.enter(...find(e, a));
|
|
305
322
|
},
|
|
306
323
|
};
|
|
@@ -421,7 +438,7 @@ export function cardRoutines(title) {
|
|
|
421
438
|
// as the observer has the original take it (the pilots' observer), and then the VIC's raster
|
|
422
439
|
// interrupt, where the game has turned it on ($D01A), at the line it set (Find Me's split of its
|
|
423
440
|
// screen into two character sets), $D019 saying which it is. On the page's clock (
|
|
424
|
-
// 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
|
|
425
442
|
// cycles of the original's time, the raster's when its line comes (routines.before). Not while
|
|
426
443
|
// the game has them masked (SEI). The routine starts as the KERNAL's own entry ($FF48) leaves
|
|
427
444
|
// it, with A, X and Y pushed, and the KERNAL's end ($EA31, $EA7B, $EA7E, $EA81), or its own PLAs
|
package/remake/engine.js
CHANGED
|
@@ -324,19 +324,32 @@ export class Engine {
|
|
|
324
324
|
}
|
|
325
325
|
const f = this.stack.pop(); if (a) this.a = this.flags(f.a); else [this.n, this.z, this.c, this.v] = f.flags;
|
|
326
326
|
}
|
|
327
|
-
// the
|
|
328
|
-
//
|
|
329
|
-
|
|
327
|
+
// the stack's place, as the original's stack pointer would be with these frames on it: each
|
|
328
|
+
// frame its bytes, a call's two (its return address) and a push's one
|
|
329
|
+
sp() { return 0xff - this.stack.reduce((n, f) => n + (f.name === undefined ? 1 : f.half ? 1 : 2), 0) & 0xff; }
|
|
330
|
+
// the frames kept at their place, for a TXS to go back to, and their bytes in the stack page as
|
|
331
|
+
// the original's are there: a call's return address, high byte first from the bottom, as JSR
|
|
332
|
+
// pushes it (half of one pulled, its high byte), and a push's byte, PHP's as the 6502's (Pieces of Eight's DOS error
|
|
333
|
+
// handler, $93F0, reads the return address under its own to see who called)
|
|
334
|
+
stackPage() {
|
|
335
|
+
const sp = this.sp();
|
|
336
|
+
(this.stacks ??= new Map()).set(sp, this.stack.map(f => ({ ...f })));
|
|
337
|
+
let at = 0xff;
|
|
338
|
+
for (const f of this.stack) {
|
|
339
|
+
const r = f.ret ?? 0;
|
|
340
|
+
if (f.name === undefined) this.mem[0x100 + at--] = f.a ?? (f.flags ? f.flags[0] << 7 | f.flags[3] << 6 | 0x30 | f.flags[1] << 1 | f.flags[2] : 0);
|
|
341
|
+
else { this.mem[0x100 + at--] = r >> 8; if (!f.half) this.mem[0x100 + at--] = r & 0xff; }
|
|
342
|
+
}
|
|
343
|
+
return sp;
|
|
344
|
+
}
|
|
345
|
+
// the machine's own instructions a scene keeps. TSX gives the stack's place (`stackPage`); TXS
|
|
346
|
+
// goes back to the frames kept at the place it is given (Detect-A-Pet's save and
|
|
330
347
|
// load keep the stack page and its pointer), and to none at any other, as the town's
|
|
331
348
|
// resets the stack and drops every script called. SED and CLD set decimal mode, and SEI and
|
|
332
349
|
// CLI the mask a Commodore 64's interrupt waits on (card.js). The rest (a byte that
|
|
333
350
|
// is no instruction) change nothing the remake holds.
|
|
334
351
|
asm(op) {
|
|
335
|
-
if (op === 'TSX')
|
|
336
|
-
const sp = 0xff - this.stack.reduce((n, f) => n + (f.name === undefined ? 1 : f.half ? 1 : 2), 0) & 0xff;
|
|
337
|
-
(this.stacks ??= new Map()).set(sp, this.stack.map(f => ({ ...f })));
|
|
338
|
-
this.x = this.flags(sp);
|
|
339
|
-
}
|
|
352
|
+
if (op === 'TSX') this.x = this.flags(this.stackPage());
|
|
340
353
|
if (op === 'TXS') {
|
|
341
354
|
const kept = this.stacks?.get(this.x);
|
|
342
355
|
this.stack = kept ? kept.map(f => ({ ...f })) : []; this.visited = null; this.inside = this.stack.filter(f => f.inside).length;
|
package/remake/load.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
// One way to start a title's remake, for 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 (
|
|
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
|
|
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
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
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); and no title named in
|
|
3
|
+
// the packages' code, only in their comments, as examples.
|
|
4
|
+
//
|
|
5
|
+
// node --test test/*.test.js
|
|
6
|
+
import { test } from 'node:test';
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { code, named } from '@moonbase-machines/player/keys.js';
|
|
11
|
+
import { savesFor } from '@moonbase-machines/player/saves.js';
|
|
12
|
+
import { openDisk } from '@moonbase-machines/player/disk/disk.js';
|
|
13
|
+
import { memory } from '@moonbase-machines/player/disk/memory.js';
|
|
14
|
+
import { Drive } from '@moonbase-machines/c64/drive.js';
|
|
15
|
+
import { make } from '@moonbase-machines/c64/d64.js';
|
|
16
|
+
|
|
17
|
+
const key = (k, mods = {}) => ({ key: k, ctrlKey: false, metaKey: false, altKey: false, ...mods });
|
|
18
|
+
|
|
19
|
+
test('a key is the Apple\'s code or the Commodore 64\'s, by the machine\'s kind', () => {
|
|
20
|
+
assert.equal(code(key('ArrowLeft'), 'prodos'), 8);
|
|
21
|
+
assert.equal(code(key('ArrowLeft'), 'c64'), 157);
|
|
22
|
+
assert.equal(code(key('Tab'), 'dos33'), 9);
|
|
23
|
+
assert.equal(code(key('a'), 'prodos'), 97, 'the Apple a small letter as typed');
|
|
24
|
+
assert.equal(code(key('a'), 'c64'), 65, 'the 64 its capital');
|
|
25
|
+
assert.equal(code(key('e', { ctrlKey: true }), 'prodos'), 5, 'control-E');
|
|
26
|
+
assert.equal(code(key('a', { metaKey: true }), 'prodos'), undefined, 'the browser\'s own');
|
|
27
|
+
assert.equal(code(key('a'), undefined), undefined, 'a title of its own engine has no table');
|
|
28
|
+
assert.equal(code(key('Shift'), 'prodos'), undefined);
|
|
29
|
+
assert.deepEqual(['space', 'left', 'A'].map(n => named(n, 'prodos')), [32, 8, 65]);
|
|
30
|
+
assert.deepEqual(['left', 'delete'].map(n => named(n, 'c64')), [157, 20]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('the saves: a game\'s volume given what is kept, and a change to its files kept', async () => {
|
|
34
|
+
const disk = await openDisk('title/one', { backend: memory() });
|
|
35
|
+
disk.write('KEPT', [1, 2], { type: 'BIN', aux: 0x2000 });
|
|
36
|
+
const game = { kind: 'prodos', volumes: { GAME: {} }, saves: { volume: 'SAVES', dos: 'prodos' } };
|
|
37
|
+
const saves = savesFor(game, disk);
|
|
38
|
+
assert.deepEqual(Object.keys(saves.data.volumes), ['GAME', 'SAVES']);
|
|
39
|
+
assert.equal(saves.data.volumes.SAVES.KEPT.type, 'BIN');
|
|
40
|
+
// a Commodore 64's drive: what it saves is kept on the disk
|
|
41
|
+
const drive = new Drive(make({ name: 'DISK', id: 'MB', files: [] }));
|
|
42
|
+
savesFor({ kind: 'c64' }, disk).drive(drive);
|
|
43
|
+
drive.keep('SAVED', Uint8Array.of(1, 8, 9));
|
|
44
|
+
await disk.written();
|
|
45
|
+
assert.deepEqual([...disk.read('SAVED')], [1, 8, 9]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('a write the backend cannot store is said, and the writes after it are stored', async () => {
|
|
49
|
+
const backend = memory(), put = backend.put;
|
|
50
|
+
let refuse = true;
|
|
51
|
+
backend.put = (d, f) => refuse && f.name === 'BAD' ? Promise.reject(new Error('full')) : put.call(backend, d, f);
|
|
52
|
+
const disk = await openDisk('title/two', { backend }), said = [];
|
|
53
|
+
disk.failed = err => said.push(err.message);
|
|
54
|
+
await assert.rejects(disk.write('BAD', [1]), /full/);
|
|
55
|
+
await disk.write('GOOD', [2]);
|
|
56
|
+
await disk.written();
|
|
57
|
+
assert.deepEqual(said, ['full']);
|
|
58
|
+
refuse = false;
|
|
59
|
+
assert.deepEqual([...(await openDisk('title/two', { backend })).read('GOOD')], [2]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// the titles, by name and by address; the packages' code, their comments taken out
|
|
63
|
+
const TITLES = /pieces[ -]of[ -]eight|detect-a-pet|goldfields|zoopak|nature[ -]park|animal[ -]rescue|aztlan|crayon|po8/i;
|
|
64
|
+
const packages = new URL('../../', import.meta.url).pathname;
|
|
65
|
+
const sources = dir => readdirSync(dir).flatMap(n => {
|
|
66
|
+
const f = join(dir, n);
|
|
67
|
+
if (['node_modules', 'dist', 'test'].includes(n)) return [];
|
|
68
|
+
return statSync(f).isDirectory() ? sources(f) : /\.m?js$/.test(n) ? [f] : [];
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('no title is named in the packages\' code, only in their comments', () => {
|
|
72
|
+
const found = [];
|
|
73
|
+
for (const f of sources(packages)) {
|
|
74
|
+
const lines = readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, c => c.replace(/[^\n]/g, '')).split('\n');
|
|
75
|
+
lines.forEach((l, i) => { const c = l.replace(/(^|\s)\/\/.*$/, ''); if (TITLES.test(c)) found.push(`${f.slice(packages.length)}:${i + 1}`); });
|
|
76
|
+
}
|
|
77
|
+
assert.deepEqual(found, []);
|
|
78
|
+
});
|