@moonbase-machines/player 0.9.1 → 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 +4 -3
- 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,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
|
|
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)
|
|
@@ -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
|
|
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
|
+
});
|
package/original.js
DELETED
|
@@ -1,200 +0,0 @@
|
|
|
1
|
-
// The original: a game's disk in the machine it was made for, its screen on a canvas, the keyboard passed
|
|
2
|
-
// on, and each picture the engine draws also given as SVG over the canvas. Each game's machine is loaded when its
|
|
3
|
-
// game asks for it, the Commodore 64 (@moonbase-machines/c64) or the Apple IIe (@moonbase-machines/apple2e), and not
|
|
4
|
-
// the other; on moonbase's own ROMs, which need nothing of the makers'.
|
|
5
|
-
//
|
|
6
|
-
// await play({ data, root, disk, words, downloadDisk, roms, studies })
|
|
7
|
-
// data the game's data, an absolute URL (<name>.json: its disks' files, its kind, its saves)
|
|
8
|
-
// root the page's section the player is made in
|
|
9
|
-
// disk the title's player's disk (disk/disk.js), opened before the player starts
|
|
10
|
-
// words the page's words the player shows: { printer: { line, print, tear } }
|
|
11
|
-
// downloadDisk a C64 game's disk offered to download, with what is saved on it
|
|
12
|
-
// and what only a workshop has, given by its pages, never looked for here:
|
|
13
|
-
// roms the makers' ROMs this machine has: an Apple game's line 1, Apple's own booting its disks,
|
|
14
|
-
// { apple: { system, disk2 }, image: async name => its disk image's bytes }; a C64 game's
|
|
15
|
-
// Commodore's, { choice ('commodore' or 'moonbase-petscii', @moonbase-machines/c64/start.js), load:
|
|
16
|
-
// async name => a ROM's bytes }
|
|
17
|
-
// studies files of its own for a C64 game's disk, a name to its bytes in base64, in the drive, not kept
|
|
18
|
-
import { Speaker } from './audio.js';
|
|
19
|
-
import { paperBox, phoneKeys, KEYS_MARKUP } from './parts.js';
|
|
20
|
-
|
|
21
|
-
export async function play({ data, root, disk, words, downloadDisk = false, roms = null, studies = null }) {
|
|
22
|
-
root.classList.add('game');
|
|
23
|
-
root.innerHTML = `<div class="stage"><svg id="vector" viewBox="0 0 140 192" preserveAspectRatio="none"></svg><canvas id="screen" width="560" height="384" tabindex="0"></canvas></div>
|
|
24
|
-
<p class="options"><span id="status">Loading.</span>${KEYS_MARKUP}<label><input type="checkbox" id="vectors"> Draw the pictures as vectors</label><label hidden><input type="checkbox" id="printer" checked> Printer</label><button id="disk" type="button" hidden>Download the disk</button></p>`;
|
|
25
|
-
root.append(paperBox(words.printer));
|
|
26
|
-
const $ = id => root.querySelector('#' + id);
|
|
27
|
-
const canvas = $('screen'), ctx = canvas.getContext('2d');
|
|
28
|
-
phoneKeys(root, canvas.parentElement);
|
|
29
|
-
// (the game's name, for a disk downloaded: its data's file's)
|
|
30
|
-
const name = new URL(data).pathname.split('/').pop().replace(/\.json$/, '');
|
|
31
|
-
const parts = { name, roms, studies, disk, downloadDisk, $, canvas, ctx, layer: $('vector'), status: $('status'), vectorToggle: $('vectors'), speaker: new Speaker() };
|
|
32
|
-
const game = await (await fetch(data)).json();
|
|
33
|
-
if (game.kind === 'c64') await c64(game, parts); else await apple(game, parts);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// the screen stretched evenly: a whole number of the device's pixels to each of the machine's dots
|
|
37
|
-
// (the 64's 384 by 272, the Apple's 280 by 192), so every dot is drawn the same size and a line one
|
|
38
|
-
// dot thick is as thick as any other; the next whole number up from the column's width, a little
|
|
39
|
-
// wider than it and centred on it, unless that is wider than the window; kept so as the window, or
|
|
40
|
-
// its zoom, changes
|
|
41
|
-
function evenly(canvas, dots, lines) {
|
|
42
|
-
const stage = canvas.parentElement, column = stage.parentElement;
|
|
43
|
-
const fit = () => {
|
|
44
|
-
const dpr = window.devicePixelRatio || 1, pad = parseFloat(getComputedStyle(column).paddingLeft) * 2;
|
|
45
|
-
const room = column.clientWidth - pad, most = document.documentElement.clientWidth - 16;
|
|
46
|
-
let k = Math.ceil(room * dpr / dots - 0.01);
|
|
47
|
-
if (dots * k / dpr > most) k = Math.max(1, Math.floor(most * dpr / dots));
|
|
48
|
-
const w = dots * k / dpr;
|
|
49
|
-
Object.assign(stage.style, { width: w + 'px', height: lines * k / dpr + 'px', aspectRatio: 'auto', marginLeft: (room - w) / 2 + 'px' });
|
|
50
|
-
};
|
|
51
|
-
fit();
|
|
52
|
-
addEventListener('resize', fit);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// An Apple II game: the machine, the canvas, the keys, and the hooks for the vectors. With Apple's
|
|
56
|
-
// ROMs and the game's disk images, where the page gives them (`roms`), it is line 1, the Apple itself booting the
|
|
57
|
-
// disks; without them, moonbase's (line 2), the game started at once.
|
|
58
|
-
async function apple(game, { roms: makers, disk, canvas, ctx, layer, status, vectorToggle, speaker }) {
|
|
59
|
-
const [{ Machine }, { placed }, { withSaves, keepSaves, onDisks, driveFiles }, { screen }, { shapes }, { elements }] = await Promise.all([
|
|
60
|
-
import('@moonbase-machines/apple2e/machine.js'), import('@moonbase-machines/apple2e/prodos.js'), import('@moonbase-machines/apple2e/saves.js'),
|
|
61
|
-
import('@moonbase-machines/apple2e/screen.js'), import('@moonbase-machines/vector/pictures.js'), import('@moonbase-machines/vector/svg.js')]);
|
|
62
|
-
evenly(canvas, 280, 192);
|
|
63
|
-
// the title's saves on its own volume, the player's disk's (@moonbase-machines/apple2e/saves.js): on line 2 the
|
|
64
|
-
// machine's files; on line 1 written onto that volume's disk before it boots, and kept as the title's DOS writes it
|
|
65
|
-
const saving = withSaves(game, disk);
|
|
66
|
-
let roms = null, disks = null;
|
|
67
|
-
if (makers?.apple) try {
|
|
68
|
-
roms = makers.apple;
|
|
69
|
-
disks = onDisks(game, placed(saving, new Map(await Promise.all(game.images.map(async n => [n, await makers.image(n)])))), disk);
|
|
70
|
-
} catch { roms = null; }
|
|
71
|
-
const m = new Machine(saving, { log: (...a) => console.log(...a), ...roms ? { roms, disks } : {} });
|
|
72
|
-
console.log(roms ? "line 1: Apple's ROMs, the disks booted" : "line 2: moonbase's machine");
|
|
73
|
-
keepSaves(game, roms ? driveFiles(m, disks, game) : m.disk, disk);
|
|
74
|
-
m.diskTime = true; // reads from the disk take the time a disk took, as the games' loading screens expect
|
|
75
|
-
|
|
76
|
-
// Where the engine is hooked, found in PART0 once it is loaded, in whichever bank the game runs
|
|
77
|
-
// in. The picture routine's loop draws the shapes the loader has put at $E000 one by one, so on
|
|
78
|
-
// its first shape the whole picture is read for the SVG, and from there to the routine's exit,
|
|
79
|
-
// which sets X to 0 and Y to $E0, the machine marks every screen byte written as the picture's.
|
|
80
|
-
// The box routine, which clears the picture window, starts by keeping its three colors, and
|
|
81
|
-
// there the SVG and the marks are cleared, and the window's color, in A, is kept to go under
|
|
82
|
-
// the next pictures' polygons.
|
|
83
|
-
let hooked = false, window_ = 0;
|
|
84
|
-
function hook() {
|
|
85
|
-
const find = (bytes, code) => { outer: for (let a = 0x4000; a < 0x8000 - bytes.length; a++) { for (let i = 0; i < bytes.length; i++) if (bytes[i] !== null && code[a + i] !== bytes[i]) continue outer; return a; } return -1; };
|
|
86
|
-
for (const code of [m.aux, m.main]) {
|
|
87
|
-
const shape = find([0x20, null, null, 0x20, null, null, 0xee, null, null, 0x4c], code); // JSR fill; JSR outline; INC count; JMP loop
|
|
88
|
-
const exit = find([0xa2, 0x00, 0xa0, 0xe0, 0x18, 0x60], code); // LDX #0; LDY #$E0; CLC; RTS
|
|
89
|
-
const box = find([0x8d, null, null, 0x8e, null, null, 0x8c, null, null, 0xad, null, null, 0xc9, 0x80, 0xf0], code);
|
|
90
|
-
if (shape < 0 || exit < 0 || box < 0) continue;
|
|
91
|
-
m.cpu.hooks.set(shape, c => { if (c.read16(0x60) === 0xe000) { layer.insertAdjacentHTML('beforeend', elements(shapes(a => m.read(a), 0xe000), { background: window_ })); m.marking = 1; } return false; });
|
|
92
|
-
m.cpu.hooks.set(exit, () => { m.marking = 0; return false; });
|
|
93
|
-
m.cpu.hooks.set(box, c => { if (c.a !== 0x80) window_ = c.a & 15; layer.innerHTML = ''; m.owner.fill(0); return false; });
|
|
94
|
-
hooked = true;
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// the keys, as the Apple II's keyboard gives them
|
|
100
|
-
const KEYS = { Escape: 27, Enter: 13, Backspace: 127, Delete: 127, ArrowLeft: 8, ArrowRight: 21, ArrowUp: 11, ArrowDown: 10, Tab: 9 };
|
|
101
|
-
window.addEventListener('keydown', e => {
|
|
102
|
-
speaker.start();
|
|
103
|
-
if (e.metaKey || e.altKey || e.repeat) return; // a held key is one key
|
|
104
|
-
// control with a letter is the Apple's control character: control-E opens Animal Rescue's main menu
|
|
105
|
-
const k = e.ctrlKey ? (/^[a-z]$/i.test(e.key) ? e.key.toUpperCase().charCodeAt(0) & 0x1f : undefined) : KEYS[e.key] ?? (e.key.length === 1 ? e.key.charCodeAt(0) : undefined);
|
|
106
|
-
if (k === undefined) return;
|
|
107
|
-
console.log('key', JSON.stringify(e.key), '->', k, 'at pc $' + m.cpu.pc.toString(16));
|
|
108
|
-
m.press(k); e.preventDefault();
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
const rgba = new Uint8ClampedArray(560 * 384 * 4);
|
|
112
|
-
const image = new ImageData(rgba, 560, 384);
|
|
113
|
-
const cols = new Uint8Array(140 * 192);
|
|
114
|
-
let mode = '';
|
|
115
|
-
function frame() {
|
|
116
|
-
if (!m.halted) m.runCycles(17030); // a frame's worth of the Apple's clock, at 60 a second
|
|
117
|
-
const now = `text ${m.sw.text} page2 ${m.sw.page2} hires ${m.sw.hires} dhires ${m.sw.dhires}`;
|
|
118
|
-
if (now !== mode) { console.log('screen:', now, 'at pc $' + m.cpu.pc.toString(16), 'cycles', m.cpu.cycles); mode = now; }
|
|
119
|
-
speaker.feed(m.clicks, m.cpu.cycles); m.clicks.length = 0;
|
|
120
|
-
if (!hooked) hook();
|
|
121
|
-
const text = m.sw.text;
|
|
122
|
-
screen(m, rgba, { cols, owner: vectorToggle.checked && layer.innerHTML ? m.owner : null });
|
|
123
|
-
ctx.putImageData(image, 0, 0);
|
|
124
|
-
layer.style.visibility = vectorToggle.checked && !text ? 'visible' : 'hidden';
|
|
125
|
-
status.textContent = m.halted ? 'The game has stopped: ' + m.halted + '.' : (m.idle > 2000 ? 'Waiting for a key.' : 'Running.');
|
|
126
|
-
requestAnimationFrame(frame);
|
|
127
|
-
}
|
|
128
|
-
canvas.addEventListener('pointerdown', () => { speaker.start(); canvas.focus(); });
|
|
129
|
-
canvas.focus();
|
|
130
|
-
frame();
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// A C64 game: on moonbase's own ROMs (@moonbase-machines/c64/rom.js), as the site has it everywhere, or on
|
|
134
|
-
// Commodore's where the page gives them (`roms`) and asks for them; the machine autostarted
|
|
135
|
-
// with LOAD"name",8,1, and RUN for a BASIC program, typed into the KERNAL's buffer, the frame on a canvas of
|
|
136
|
-
// 384 by 272 doubled, the keys as the matrix and the arrows as the joystick.
|
|
137
|
-
async function c64(game, { name, roms: makers, studies, disk, downloadDisk, $, canvas, layer, status, vectorToggle, speaker }) {
|
|
138
|
-
const [{ C64 }, { Printer, paper }, { WIDTH, HEIGHT }, { keyboard, painter }, { SID }, { c64Roms, typed: startTyped }, { add }, { keepDrive }] = await Promise.all([
|
|
139
|
-
import('@moonbase-machines/c64/machine.js'), import('@moonbase-machines/c64/printer.js'), import('@moonbase-machines/c64/vic.js'), import('@moonbase-machines/c64/browser.js'),
|
|
140
|
-
import('@moonbase-machines/c64/sid.js'), import('@moonbase-machines/c64/start.js'), import('@moonbase-machines/c64/d64.js'), import('@moonbase-machines/c64/saves.js')]);
|
|
141
|
-
evenly(canvas, WIDTH, HEIGHT);
|
|
142
|
-
const roms = await c64Roms(makers?.choice ?? null, makers?.load);
|
|
143
|
-
if (!roms) { status.textContent = 'Commodore\'s ROMs, asked for, are not here.'; return; }
|
|
144
|
-
// a printer on device 4 (@moonbase-machines/c64/printer.js): on unless its box is off, what it prints drawn
|
|
145
|
-
// below the screen as a sheet of paper, until it is torn off
|
|
146
|
-
const box = $('printer'), sheet = $('paper'), shown = $('paper-box');
|
|
147
|
-
try { box.checked = localStorage.getItem('printer') !== '0'; } catch {}
|
|
148
|
-
box.parentElement.hidden = false;
|
|
149
|
-
const printer = new Printer({ on: box.checked });
|
|
150
|
-
box.addEventListener('change', () => { printer.on = box.checked; try { localStorage.setItem('printer', box.checked ? '1' : '0'); } catch {} });
|
|
151
|
-
printer.changed = () => { shown.hidden = !printer.lines.length; if (printer.lines.length) paper(sheet, printer, roms.chargen); };
|
|
152
|
-
$('tear').addEventListener('click', () => printer.tear());
|
|
153
|
-
$('print-paper').addEventListener('click', () => window.print()); // (the sheet alone: its print style)
|
|
154
|
-
const m = new C64(game, roms, { log: (...a) => console.log(...a), printer });
|
|
155
|
-
// What the game SAVEs to its disk, kept on the player's disk and given back to the drive the next time
|
|
156
|
-
// (@moonbase-machines/c64/saves.js)
|
|
157
|
-
keepDrive(m.drive, disk);
|
|
158
|
-
// (and, where the page gives them, `studies`, files of its own for the disk, a name to a file: in the drive, not
|
|
159
|
-
// kept)
|
|
160
|
-
const raw = t => Uint8Array.from(atob(t), c => c.charCodeAt(0));
|
|
161
|
-
for (const [file, data] of Object.entries(studies ?? {})) m.drive.saved.set(file, raw(data));
|
|
162
|
-
// the disk as it now stands, its own files and those saved, to download where the title offers it
|
|
163
|
-
// (Crayon's): a .d64 that VICE or a real 64 can use (@moonbase-machines/c64/d64.js `add`)
|
|
164
|
-
if (downloadDisk) {
|
|
165
|
-
const download = $('disk');
|
|
166
|
-
download.hidden = false;
|
|
167
|
-
download.addEventListener('click', () => {
|
|
168
|
-
const image = add(m.drive.image, [...m.drive.saved].map(([name, data]) => ({ name, data })));
|
|
169
|
-
const a = Object.assign(document.createElement('a'), { href: URL.createObjectURL(new Blob([image], { type: 'application/octet-stream' })), download: name + '.d64' });
|
|
170
|
-
a.click(); URL.revokeObjectURL(a.href);
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
layer.style.display = 'none'; vectorToggle.parentElement.style.display = 'none';
|
|
174
|
-
const draw = painter(canvas);
|
|
175
|
-
let typing = null, typed = 0;
|
|
176
|
-
const keys = keyboard(m, { onKey: () => speaker.start(), log: true });
|
|
177
|
-
// once BASIC is ready, the start typed in; the KERNAL's buffer takes ten characters at a time.
|
|
178
|
-
// The C64 runs 50 frames a second, so frames are run to keep up with the clock, two at most.
|
|
179
|
-
let frames = 0, sid = null, started = performance.now();
|
|
180
|
-
function frame() {
|
|
181
|
-
const due = Math.min(2, Math.floor((performance.now() - started) / 20) - frames);
|
|
182
|
-
for (let i = 0; i < due; i++) {
|
|
183
|
-
m.runFrame(); frames++; keys.frame();
|
|
184
|
-
if (speaker.node) {
|
|
185
|
-
if (!sid) sid = new SID(speaker.rate);
|
|
186
|
-
const n = Math.round(19656 / 985248 * speaker.rate), first = m.cpu.cycles - 19656;
|
|
187
|
-
const wave = sid.render(n, m.sidWrites.map(([c, r, v]) => [Math.floor((c - first) / 19656 * n), r, v]));
|
|
188
|
-
speaker.node.port.postMessage(wave, [wave.buffer]);
|
|
189
|
-
}
|
|
190
|
-
m.sidWrites.length = 0;
|
|
191
|
-
}
|
|
192
|
-
// (LOAD, and RUN for a BASIC program: @moonbase-machines/c64/start.js)
|
|
193
|
-
if (frames >= 120 && typing === null && !typed) { typing = startTyped(m, game); typed = 1; }
|
|
194
|
-
if (typing !== null && !m.ram[0xc6]) { typing = m.type(typing); if (!typing.length) typing = null; }
|
|
195
|
-
draw(m.vic.frame);
|
|
196
|
-
status.textContent = m.halted ? 'The game has stopped: ' + m.halted + '.' : 'Running.';
|
|
197
|
-
requestAnimationFrame(frame);
|
|
198
|
-
}
|
|
199
|
-
canvas.focus(); frame();
|
|
200
|
-
}
|