@moonbase-machines/player 0.0.0-stage → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js ADDED
@@ -0,0 +1,28 @@
1
+ // @moonbase-machines/player: every module by name, for one bundled file or to take the whole package at once:
2
+ // import { remakeMain } from '@moonbase-machines/player'. Each module can be imported on its own too:
3
+ // '@moonbase-machines/player/<module>.js' (remake/main.js, original.js, disk/disk.js).
4
+ export * as original from './original.js';
5
+ export * as audio from './audio.js';
6
+ export * as modal from './modal.js';
7
+ export * as parts from './parts.js';
8
+ export * as diskDialogs from './disk/dialogs.js';
9
+ export * as diskDisk from './disk/disk.js';
10
+ export * as diskLocal from './disk/local.js';
11
+ export * as diskMemory from './disk/memory.js';
12
+ export * as remakeC64View from './remake/c64-view.js';
13
+ export * as remakeCardView from './remake/card-view.js';
14
+ export * as remakeCard from './remake/card.js';
15
+ export * as remakeCards from './remake/cards.js';
16
+ export * as remakeCompile from './remake/compile.js';
17
+ export * as remakeEngine from './remake/engine.js';
18
+ export * as remakeEvents from './remake/events.js';
19
+ export * as remakeLayout from './remake/layout.js';
20
+ export * as remakeLift from './remake/lift.js';
21
+ export * as remakeLoad from './remake/load.js';
22
+ export * as remakeMain from './remake/main.js';
23
+ export * as remakeProblems from './remake/problems.js';
24
+ export * as remakeRaster from './remake/raster.js';
25
+ export * as remakeScenes from './remake/scenes.js';
26
+ export * as remakeScript from './remake/script.js';
27
+ export * as remakeVector from './remake/vector.js';
28
+ export * as remakeView from './remake/view.js';
package/modal.js ADDED
@@ -0,0 +1,68 @@
1
+ // A modal window, the one way every dialog and window over a game is shown: the disk's dialogs
2
+ // (disk/dialogs.js) and a program's own windows (Aztlan's help and About box, its macapp.js). It opens
3
+ // inside the place it is given, the game's screen, not over the whole page: what it covers is that place
4
+ // alone, which scrolls with it, and the rest of the page is the page's. Its frame and look are the place's
5
+ // (a class, as Aztlan's window definition 1 has it, and the place's own styles); without a place, the page.
6
+ //
7
+ // const m = modal({ within, className, content, cancel })
8
+ // within the element it opens in (positioned), else the page
9
+ // className its frame's classes
10
+ // content what it shows
11
+ // cancel() what Escape does (closing it, else)
12
+ // m.box, m.close()
13
+ // While it is open the rest of `within` is inert, and the keys are the topmost one's, wherever the focus
14
+ // is (WebKit leaves it on the page when a button is clicked): Escape cancels; Return presses its default
15
+ // button (`.default`), unless on a button, or in a box that takes Return itself; Tab stays inside it. When
16
+ // it closes the focus goes back where it was, or into the window under it.
17
+ const open = [];
18
+ // (listened for from the first one opened: a module the tests load in Node, with no page)
19
+ let listening = false;
20
+ const keys = e => {
21
+ const m = open.at(-1);
22
+ if (!m) return;
23
+ if (e.key === 'Escape') { e.preventDefault(); m.cancel(); }
24
+ else if (e.key === 'Enter' && !(e.target instanceof HTMLButtonElement) && e.target.tagName !== 'TEXTAREA') {
25
+ const d = m.box.querySelector('.default:not(:disabled)');
26
+ if (d) { e.preventDefault(); d.click(); }
27
+ }
28
+ else if (e.key === 'Tab') {
29
+ const f = focusable(m.box), at = f.indexOf(document.activeElement);
30
+ if (f.length) { e.preventDefault(); f[(at + (e.shiftKey ? f.length - 1 : 1)) % f.length].focus(); }
31
+ }
32
+ // (the game under it has none of the keys)
33
+ e.stopPropagation();
34
+ };
35
+ const focusable = box => [...box.querySelectorAll('button:not(:disabled), input:not([type=hidden]):not([type=file]):not([hidden]), select:not([hidden]), [tabindex="0"]')];
36
+ const first = box => box.querySelector('input:not([type=file]), select:not([hidden])') ?? box.querySelector('.default') ?? focusable(box)[0];
37
+
38
+ export function modal({ within = document.body, className = '', content, cancel = null }) {
39
+ if (!listening) { document.addEventListener('keydown', keys, true); listening = true; }
40
+ const layer = document.createElement('div');
41
+ layer.className = 'modal-layer' + (within === document.body ? ' page' : '');
42
+ const box = document.createElement('div');
43
+ box.className = 'modal ' + className;
44
+ box.setAttribute('role', 'dialog'); box.setAttribute('aria-modal', 'true');
45
+ box.append(content); layer.append(box);
46
+ const before = document.activeElement;
47
+ const others = [...within.children].filter(el => !el.inert);
48
+ for (const el of others) el.inert = true;
49
+ within.append(layer);
50
+ const m = {
51
+ box,
52
+ cancel: () => (cancel ?? m.close)(),
53
+ close() {
54
+ layer.remove();
55
+ open.splice(open.indexOf(m), 1);
56
+ for (const el of others) el.inert = false;
57
+ const under = open.at(-1);
58
+ (under && !under.box.contains(before) ? first(under.box) : before)?.focus?.({ preventScroll: true });
59
+ },
60
+ };
61
+ open.push(m);
62
+ // (the first box to type in or list to choose from, else the default button, has the focus)
63
+ first(box)?.focus({ preventScroll: true });
64
+ return m;
65
+ }
66
+
67
+ // whether a modal window is open: a game's keys and commands wait while one is
68
+ export const modalOpen = () => open.length > 0;
package/original.js ADDED
@@ -0,0 +1,200 @@
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
+ }
package/package.json CHANGED
@@ -1,6 +1,27 @@
1
1
  {
2
2
  "name": "@moonbase-machines/player",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.9.0",
4
+ "description": "The player for Mark Wharton's titles of 1985 to 1995: the Original, a game's own disk on Moonbase's machines, and the Remake, its scenes on an engine; the player's own disk of saves in the browser.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Mark Wharton",
8
+ "exports": {
9
+ ".": "./index.js",
10
+ "./*": "./*"
11
+ },
12
+ "bin": {
13
+ "moonbase-compile": "./bin/moonbase-compile.mjs",
14
+ "moonbase-play": "./bin/moonbase-play.mjs"
15
+ },
16
+ "dependencies": {
17
+ "@moonbase-machines/apple2e": "0.9.0",
18
+ "@moonbase-machines/c64": "0.9.0",
19
+ "@moonbase-machines/charset": "0.9.0",
20
+ "@moonbase-machines/mos6502": "0.9.0",
21
+ "@moonbase-machines/vector": "0.9.0"
22
+ },
23
+ "scripts": {
24
+ "test": "node --test test/*.test.js",
25
+ "prepack": "node ../../tools/package-files.mjs player"
26
+ }
27
+ }
package/parts.js ADDED
@@ -0,0 +1,33 @@
1
+ // The parts both players make the same: the printer's paper under the screen, and a phone's keyboard.
2
+ //
3
+ // paperBox(words) the paper's box (hidden until there is something printed): its line, Print and
4
+ // Tear off, and the sheet (#paper-box, #print-paper, #tear, #paper)
5
+ // phoneKeys(root, stage) on a phone, the Keyboard button (#keyboard) opens its keyboard on a box (#keys-box)
6
+ // whose keys go to the window's own listener; a keyboard that names no key gives
7
+ // its characters, passed on as keys
8
+ export function paperBox({ line, print, tear }) {
9
+ const box = document.createElement('div');
10
+ box.id = 'paper-box'; box.hidden = true;
11
+ box.innerHTML = '<p><span></span> <button id="print-paper" type="button"></button> <button id="tear" type="button"></button></p><canvas id="paper"></canvas>';
12
+ box.querySelector('span').textContent = line;
13
+ box.querySelector('#print-paper').textContent = print;
14
+ box.querySelector('#tear').textContent = tear;
15
+ return box;
16
+ }
17
+
18
+ export function phoneKeys(root, stage) {
19
+ const kb = root.querySelector('#keyboard'), box = root.querySelector('#keys-box');
20
+ if (!kb || !box || !matchMedia('(pointer: coarse)').matches) return;
21
+ kb.hidden = false;
22
+ kb.addEventListener('click', () => box.focus());
23
+ stage.addEventListener('click', () => box.focus());
24
+ let unnamed = false;
25
+ box.addEventListener('keydown', e => { unnamed = e.key === 'Unidentified'; });
26
+ box.addEventListener('input', () => {
27
+ if (unnamed) for (const ch of box.value) for (const t of ['keydown', 'keyup']) window.dispatchEvent(new KeyboardEvent(t, { key: ch }));
28
+ box.value = '';
29
+ });
30
+ }
31
+
32
+ // the options line's keyboard parts, as both players have them
33
+ export const KEYS_MARKUP = '<button type="button" id="keyboard" class="full" hidden>Keyboard</button><input id="keys-box" class="keys-box" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" aria-label="Keys for the game">';
package/player.css ADDED
@@ -0,0 +1,48 @@
1
+ /* The player's own look: the stage the game is drawn on, the options line under it, the paper a C64 game's printer
2
+ prints on, and the modal window and the disk's dialogs (modal.js, disk/dialogs.js). Its colours are the page's,
3
+ where the page names them (--screen, --muted, --gold, --alert, --line, --surface, --ink, --bg, --accent,
4
+ --accent-ink, --sans), else these. */
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; }
9
+ /* 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); }
16
+ .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
+ #status.error { color: var(--alert, #9b3d2e); }
18
+ #paper { display: block; width: 100%; height: auto; border-radius: 2px; box-shadow: 0 2px 8px #0005; }
19
+ #paper-box { max-width: 960px; margin: 0 auto; }
20
+ @media print {
21
+ @page { size: landscape; margin: 10mm; }
22
+ body { background: #fff; }
23
+ body *:not(:has(#paper)):not(#paper) { display: none; }
24
+ main, section, #paper-box { margin: 0; padding: 0; max-width: none; }
25
+ #paper { width: 100%; box-shadow: none; }
26
+ }
27
+ /* the button that opens a phone's keyboard, and the box it types into: there, and unseen */
28
+ .full { font: inherit; font-size: 13px; padding: 3px 10px; border-radius: 99px; border: 1px solid var(--line, #e4dfd3); background: transparent; color: var(--muted, #5d6475); cursor: pointer; }
29
+ .full:hover { color: var(--ink, #1b2233); border-color: var(--gold, #a8801f); }
30
+ .keys-box { position: absolute; width: 1px; height: 1px; opacity: 0; border: 0; padding: 0; pointer-events: none; }
31
+
32
+ /* a modal window and the disk's dialogs in it: over the place it opens in, a game's screen, its frame and look that
33
+ game's own where it has one (its title's stylesheet); on the page, the page's */
34
+ .modal-layer { position: absolute; inset: 0; z-index: 20; display: grid; place-items: center; }
35
+ .modal-layer.page { position: fixed; background: #0007; }
36
+ .modal { border: 1px solid var(--line, #e4dfd3); border-radius: 12px; background: var(--surface, #fff); color: var(--ink, #1b2233); font: 16px/1.5 var(--sans, system-ui, sans-serif); padding: 20px 22px; width: min(560px, calc(100vw - 32px)); box-shadow: 0 20px 50px -20px #0008; }
37
+ .modal .disk h2 { font-size: 20px; margin: 0 0 12px; }
38
+ .modal .disk label { display: flex; gap: 8px; align-items: center; }
39
+ .modal .disk input, .modal .disk select { font: inherit; background: var(--bg, #fbf9f4); color: var(--ink, #1b2233); border: 1px solid var(--line, #e4dfd3); border-radius: 6px; padding: 6px 8px; }
40
+ .modal .disk label input { flex: 1; min-width: 0; }
41
+ .modal .disk select { display: block; width: 100%; margin: 6px 0 4px; }
42
+ .modal .disk .note { margin: 0; color: var(--muted, #5d6475); font-size: 15px; }
43
+ .modal .disk button { font: 600 15px var(--sans, system-ui, sans-serif); padding: 7px 14px; border-radius: 8px; border: 1px solid var(--line, #e4dfd3); background: transparent; color: var(--ink, #1b2233); cursor: pointer; white-space: nowrap; }
44
+ .modal .disk button:disabled { opacity: 0.45; cursor: default; }
45
+ .modal .disk .buttons { display: flex; justify-content: flex-end; flex-wrap: nowrap; gap: 8px; margin: 18px 0 0; }
46
+ .modal .disk .buttons .aside { display: flex; gap: 8px; margin-right: auto; }
47
+ @media (max-width: 600px) { .modal .disk .buttons { flex-wrap: wrap; } }
48
+ .modal .disk .buttons .default { background: var(--accent, #1d2a4a); color: var(--accent-ink, #f3e3b5); border-color: var(--accent, #1d2a4a); }
@@ -0,0 +1,30 @@
1
+ // The view of a Commodore 64 title (Zoopak): the frame the VIC-II paints from the remake's
2
+ // memory and chips (@moonbase-machines/c64/paint.js), border and all, 384 by 272 in the machine's colours. The
3
+ // character ROM, which a text screen in the machine's own letters needs, is the one the emulator
4
+ // is given (the page's `roms`), when it is there.
5
+ import { paint } from '@moonbase-machines/c64/paint.js';
6
+ import { PALETTE, WIDTH, HEIGHT } from '@moonbase-machines/c64/vic.js';
7
+
8
+ const NS = 'http://www.w3.org/2000/svg';
9
+
10
+ export class C64View {
11
+ constructor(svg, { engine, chargen }) {
12
+ this.svg = svg; this.engine = engine; this.chargen = chargen;
13
+ svg.setAttribute('viewBox', `0 0 ${WIDTH * 2} ${HEIGHT * 2}`);
14
+ this.canvas = document.createElement('canvas'); this.canvas.width = WIDTH; this.canvas.height = HEIGHT;
15
+ this.image = document.createElementNS(NS, 'image');
16
+ for (const [k, v] of Object.entries({ x: 0, y: 0, width: WIDTH * 2, height: HEIGHT * 2, preserveAspectRatio: 'none', style: 'image-rendering: pixelated' })) this.image.setAttribute(k, v);
17
+ svg.append(this.image);
18
+ }
19
+ snapshot(e) { return e; }
20
+ apply() {}
21
+ prompts() {
22
+ const e = this.engine, frame = paint(e.mem, e.title.chip, this.chargen, this.vic, e.title.splits);
23
+ const ctx = this.canvas.getContext('2d'), pixels = ctx.createImageData(WIDTH, HEIGHT);
24
+ for (let i = 0; i < frame.length; i++) { const [r, g, b] = PALETTE[frame[i]]; pixels.data.set([r, g, b, 255], i * 4); }
25
+ ctx.putImageData(pixels, 0, 0);
26
+ // and the border's colour on either side, where the page is wider than the frame
27
+ this.svg.style.background = `rgb(${PALETTE[frame[0]].join(',')})`;
28
+ this.image.setAttribute('href', this.canvas.toDataURL());
29
+ }
30
+ }
@@ -0,0 +1,53 @@
1
+ // The view of a card toolkit title (card.js): the screen as the remake's memory holds
2
+ // it, the hi-res page the soft switches show, or the text screen, painted dot for dot as the
3
+ // machine's page paints it (@moonbase-machines/apple2e/screen.js), 560 by 384. The toolkit's pictures are bitmaps,
4
+ // and this is them as they are; drawing them sharper is for later.
5
+ //
6
+ // `inset` (a title's, its title.js), when set on: the screen's band at the top, where it is (its rows above
7
+ // `top` all white), its dots `move` moved `by`, to make room for a mini view; `banded`, whether the band was there
8
+ // at the last painting.
9
+ import { paintHires, paintText } from '@moonbase-machines/apple2e/screen.js';
10
+
11
+ const NS = 'http://www.w3.org/2000/svg';
12
+
13
+ export class CardView {
14
+ constructor(svg, { engine, inset = null }) {
15
+ this.svg = svg; this.engine = engine; this.inset = inset; this.insetOn = false; this.banded = false;
16
+ svg.setAttribute('viewBox', '0 0 560 384');
17
+ this.canvas = document.createElement('canvas'); this.canvas.width = 560; this.canvas.height = 384;
18
+ this.image = document.createElementNS(NS, 'image');
19
+ for (const [k, v] of Object.entries({ x: 0, y: 0, width: 560, height: 384, preserveAspectRatio: 'none', style: 'image-rendering: pixelated' })) this.image.setAttribute(k, v);
20
+ svg.append(this.image);
21
+ }
22
+ snapshot(e) { return e; }
23
+ apply() {}
24
+ // the screen, when a run has gone as far as it goes
25
+ prompts() {
26
+ const mem = this.engine.mem, s = this.engine.title.screen, ctx = this.canvas.getContext('2d');
27
+ const pixels = ctx.createImageData(560, 384);
28
+ if (s.text) paintText(pixels.data, mem, mem, false, [0, 24]);
29
+ else {
30
+ const page = s.page2 ? 0x4000 : 0x2000;
31
+ paintHires(pixels.data, mem.subarray(page, page + 0x2000));
32
+ if (s.mixed) paintText(pixels.data, mem, mem, false, [20, 24]);
33
+ }
34
+ this.banded = this.insetOn && !!this.inset && band(pixels.data, this.inset);
35
+ if (this.banded) shift(pixels.data, this.inset);
36
+ ctx.putImageData(pixels, 0, 0);
37
+ this.image.setAttribute('href', this.canvas.toDataURL());
38
+ }
39
+ }
40
+
41
+ // (whether the band is on the screen: its rows above `top` white, the whole width)
42
+ function band(px, { top }) {
43
+ for (let y = 0; y < top; y++) for (let x = 0; x < 560; x++) { const i = (y * 560 + x) * 4; if (px[i] < 200 || px[i + 1] < 200 || px[i + 2] < 200) return false; }
44
+ return true;
45
+ }
46
+ // (the band's rows `top` to `bottom`: the dots `move` moved `by`, white where they were)
47
+ function shift(px, { top, bottom, move: [from, to], by }) {
48
+ for (let y = top; y <= bottom; y++) {
49
+ const row = y * 560 * 4;
50
+ px.copyWithin(row + (from + by) * 4, row + from * 4, row + to * 4);
51
+ if (by < 0) px.fill(255, row + (to + by) * 4, row + to * 4); else px.fill(255, row + from * 4, row + (from + by) * 4);
52
+ }
53
+ }