@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/remake/view.js ADDED
@@ -0,0 +1,479 @@
1
+ // The remake's view: it follows the engine's events and draws the screen as SVG, sharper than
2
+ // the original. The screen is the original's, 140 dots across and 192 down, each dot four
3
+ // pixels wide and two high, so the SVG is 560 by 384. Pictures are drawn from their shapes,
4
+ // smooth; objects from their own dots; text in a system font, laid out by the engine's codes.
5
+ //
6
+ // The screen is kept as the original painted it: each thing drawn covers some rows, and a
7
+ // window, a box, a panel or a fill that paints over those rows takes away what was there.
8
+ import { COLORS, rgb } from '@moonbase-machines/apple2e/colors.js';
9
+ import { pictures } from '@moonbase-machines/vector/pictures.js';
10
+ import { tables, put } from '@moonbase-machines/vector/objects.js';
11
+ import { codeFiles } from './vector.js';
12
+ import { elements } from '@moonbase-machines/vector/svg.js';
13
+ import { width, dots } from '@moonbase-machines/vector/font.js';
14
+ import { layout, resolve, LAYOUT, PANELS, textLayout, textPen, textPage } from './layout.js';
15
+ import { FONT } from '@moonbase-machines/charset/font.js';
16
+ import { colors } from '@moonbase-machines/apple2e/screen.js';
17
+
18
+ const NS = 'http://www.w3.org/2000/svg';
19
+ const X = x => x * 4, Y = y => y * 2;
20
+ const SANS = 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif';
21
+ const MONO = 'ui-monospace, Menlo, Consolas, "Liberation Mono", monospace';
22
+
23
+ // The characters are the title's own. There was no Unicode in 1986: the engine's font has its
24
+ // arrows at $5B to $5E, left, right, down and up, where ASCII has [ \ ] and ^. The engine's own
25
+ // font draws those characters as they are. A modern font, a system font or one from Bunny Fonts,
26
+ // has no arrow there, so text drawn in one maps them to Unicode's arrows as it is drawn. A title's
27
+ // own glyphs that a modern font lacks are mapped the same way, as its title says (its map's `modern`):
28
+ // Nature Park's underline is a band of checked dots.
29
+ const ARROWS = ['[', '\\', ']', '^'];
30
+ const MODERN = { '[': '←', '\\': '→', ']': '↓', '^': '↑' };
31
+
32
+ export class View {
33
+ // `svg` is the element to draw in; `disk` the title's disk from its game's data; `engine` the
34
+ // running engine; `extra` the title's own drawing for its problems (titles/<title>/view.js)
35
+ constructor(svg, { disk, volume, engine, extra = {}, press }) {
36
+ this.svg = svg; this.engine = engine; this.extra = extra; this.press = press;
37
+ svg.setAttribute('viewBox', '0 0 560 384');
38
+ this.disk = disk.volumes[volume];
39
+ this.pictures = new Map();
40
+ for (const [name, f] of Object.entries(disk.volumes[volume])) if (/^PICS\d$/.test(name)) for (const p of pictures(Uint8Array.from(atob(f.data), c => c.charCodeAt(0)))) this.pictures.set(p.name, p.shapes);
41
+ const map = engine.title.map;
42
+ this.vars = map.vars;
43
+ // the title's characters a modern font would draw wrongly, and what it draws instead
44
+ const modern = { ...MODERN, ...map.modern };
45
+ for (let d = 0; d < 10; d++) modern[String.fromCharCode(0x80 + d)] = String(d); // the big digits
46
+ const pattern = new RegExp('[' + Object.keys(modern).map(c => '\\' + c).join('') + ']', 'g');
47
+ this.modern = s => s.replace(pattern, c => modern[c]);
48
+ this.tables = new Map(tables(codeFiles(map, disk.volumes[volume]), map.objects).map(t => [t.address, t.objects]));
49
+ this.items = []; // what is on the screen: { top, bottom, el }
50
+ this.pen = { x: 0, y: 0, margin: 0, centre: false, spacing: 1, gap: 0, ink: 15, paper: 10 };
51
+ this.window = { color: 0, bottom: 116 };
52
+ this.shapes = []; // the shapes in the window now
53
+ this.spaceBar = null;
54
+ this.layer = this.el('g'); svg.append(this.layer);
55
+ this.textScreen = this.el('g', { display: 'none' }); svg.append(this.textScreen);
56
+ this.prompt = this.el('g'); svg.append(this.prompt);
57
+ this.textPen = textPen(); this.textCells = textPage();
58
+ this.rect(0, 0, 140, 192, 15, this.layer);
59
+ }
60
+
61
+ el(name, attrs = {}, parent) { const e = document.createElementNS(NS, name); for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v); parent?.append(e); return e; }
62
+ // a flat rectangle, its edges on whole pixels: where two meet at a fraction of a pixel, the page
63
+ // would otherwise show a hair of what lies under both
64
+ rect(x, y, w, h, color, parent = this.layer) { return this.el('rect', { x: X(x), y: Y(y), width: X(w), height: Y(h), fill: rgb(color), 'shape-rendering': 'crispEdges' }, parent); }
65
+ // something drawn over rows top to bottom: what it covers entirely is gone
66
+ // Something drawn over the rows top to bottom and the columns left to right: what lies wholly
67
+ // inside that, it covers, and is gone.
68
+ add(top, bottom, el, { covers = true, left = 0, right = 139 } = {}) {
69
+ if (covers) this.items = this.items.filter(i => { if (i.top >= top && i.bottom <= bottom && i.left >= left && i.right <= right) { i.el.remove(); return false; } return true; });
70
+ this.items.push({ top, bottom, left, right, el }); this.layer.append(el);
71
+ return el;
72
+ }
73
+ // something taken off the screen, and out of what the screen holds
74
+ remove(el) { el.remove(); this.items = this.items.filter(i => i.el !== el); }
75
+ fill(top, bottom, color, sides = true) {
76
+ const g = this.el('g');
77
+ this.rect(0, top, 140, bottom - top + 1, color, g);
78
+ if (sides) { this.rect(0, top, 1, bottom - top + 1, 15, g); this.rect(139, top, 1, bottom - top + 1, 15, g); }
79
+ this.add(top, bottom, g);
80
+ }
81
+ whiteLines(y) { this.add(y, y + 1, this.rect(0, y, 140, 2, 15, null)); }
82
+
83
+ // What the view needs from memory as an event happens, since it draws the event later: the
84
+ // layout, the difficulty, and the words code 6 takes from its tables.
85
+ snapshot(e) {
86
+ const mem = this.engine.mem;
87
+ e.layout = mem[this.vars.layout]; e.difficulty = mem[this.vars.difficulty];
88
+ if (e.step === 'text') e.words = resolve(e.words, mem);
89
+ if (e.step === 'span') e.row = mem[this.vars.zero + 0x02];
90
+ return e;
91
+ }
92
+
93
+ // an event from the engine
94
+ apply(e) {
95
+ this.difficulty = e.difficulty;
96
+ if (this.spaceBar?.erase && !this.waiting()) { this.fill(this.spaceBar.y, this.spaceBar.y + 10, 5); this.spaceBar.el.remove(); this.spaceBar = null; }
97
+ // a step the title draws itself comes first, so that no name of the view's own can take it
98
+ const f = this.extra[e.step] ?? (typeof this[e.step] === 'function' ? this[e.step] : undefined);
99
+ if (f) f.call(this, e);
100
+ }
101
+ waiting() { return this.engine.waitingOn; }
102
+
103
+ // The text screen ($573D), where the menus are: 40 columns and 24 rows over the whole screen,
104
+ // white on black. `show` ($5BA9) puts the graphics back as they were.
105
+ 'text-screen'() {
106
+ this.textCells = textPage();
107
+ this.textScreen.replaceChildren(); this.rect(0, 0, 140, 192, 0, this.textScreen);
108
+ this.textScreen.setAttribute('display', 'inline'); this.layer.setAttribute('display', 'none');
109
+ }
110
+ show() { this.textScreen.setAttribute('display', 'none'); this.layer.setAttribute('display', 'inline'); }
111
+ // a character of the text screen, 14 pixels by 16, in inverse or not
112
+ textCell(row, col, ch, inverse) {
113
+ const old = this.textCells[row][col];
114
+ old.el?.remove();
115
+ const g = this.el('g', { 'data-cell': row * 40 + col }, this.textScreen), x = col * 14, y = row * 16, ink = inverse ? '#000' : '#fff';
116
+ if (inverse) this.el('rect', { x, y, width: 14, height: 16, fill: '#fff', 'shape-rendering': 'crispEdges' }, g);
117
+ if (this.oldStyle) {
118
+ // the emulator's own letters, five dots by seven in a cell of seven by eight, two pixels a dot
119
+ const glyph = FONT[ch.charCodeAt(0) - 0x20] ?? FONT[0];
120
+ let d = ''; for (let gx = 0; gx < 5; gx++) for (let gy = 0; gy < 7; gy++) if (glyph[gx] >> gy & 1) d += `M${x + 2 + gx * 2} ${y + gy * 2}h2v2h-2z`;
121
+ if (d) this.el('path', { d, fill: ink }, g);
122
+ } else if (ch !== ' ') {
123
+ const t = this.el('text', { x: x + 7, y: y + 12, fill: ink, 'font-family': MONO, 'font-size': 15, 'text-anchor': 'middle' }, g);
124
+ t.textContent = ch;
125
+ }
126
+ this.textCells[row][col] = { ch, inverse, el: g };
127
+ }
128
+
129
+ // The loader's splash: TITLE, a double hi-res screen, the auxiliary half then the main, drawn
130
+ // dot for dot as it is
131
+ splash() {
132
+ const f = this.disk.TITLE;
133
+ if (!f) return;
134
+ const bytes = Uint8Array.from(atob(f.data), c => c.charCodeAt(0)), dots = colors(bytes.subarray(0, 0x2000), bytes.subarray(0x2000));
135
+ const rows = []; for (let y = 0; y < 192; y++) rows.push(Array.from(dots.subarray(y * 140, y * 140 + 140)));
136
+ this.clear();
137
+ this.add(0, 191, this.el('image', { href: this.image(rows), x: 0, y: 0, width: 560, height: 384, preserveAspectRatio: 'none', style: 'image-rendering: pixelated' }));
138
+ }
139
+ // Nature Park's $4C00: the screen cleared to black
140
+ 'clear-screen'() { this.clear(); this.add(0, 191, this.rect(0, 0, 140, 192, 0, null)); }
141
+ clear() { for (const i of this.items) i.el.remove(); this.items = []; }
142
+ // Nature Park's menus over a game ($4C5D and $4CFB): the screen kept, cleared to black, and put
143
+ // back as it was when they return
144
+ 'keep-screen'() {
145
+ this.kept = { items: this.items, pen: { ...this.pen }, window: { ...this.window }, shapes: this.shapes, spaceBar: this.spaceBar };
146
+ for (const i of this.items) i.el.remove();
147
+ this.items = []; this.spaceBar = null;
148
+ this['clear-screen']();
149
+ }
150
+ 'put-screen'() {
151
+ if (!this.kept) return;
152
+ this.clear();
153
+ const k = this.kept; this.kept = null;
154
+ for (const i of k.items) this.layer.append(i.el);
155
+ Object.assign(this, { items: k.items, pen: k.pen, window: k.window, shapes: k.shapes, spaceBar: k.spaceBar });
156
+ }
157
+
158
+ box({ a, x, y, layout }) {
159
+ const [bottom, top1, top2] = LAYOUT[layout] ?? LAYOUT[0x32];
160
+ // all three painted: the whole screen is, and nothing under it is left
161
+ if (a !== 0x80 && x !== 0x80 && y !== 0x80) { for (const i of this.items) i.el.remove(); this.items = []; }
162
+ if (a !== 0x80) {
163
+ const g = this.el('g');
164
+ this.rect(1, 1, 138, bottom - 1, a, g);
165
+ this.el('rect', { x: X(1) + 2, y: Y(1) + 1, width: X(138) - 4, height: Y(bottom - 2) - 2, fill: 'none', stroke: 'black', 'stroke-width': 4 }, g);
166
+ this.add(1, bottom - 1, g, { left: 1, right: 138 });
167
+ this.window = { color: a, bottom }; this.shapes = [];
168
+ }
169
+ if (x !== 0x80) { this.whiteLines(top1); this.fill(top1 + 2, top2 - 1, x); }
170
+ if (y !== 0x80) { this.whiteLines(top2); this.fill(top2 + 2, 189, y); }
171
+ }
172
+ panel({ n: [n] }) {
173
+ const p = PANELS[n];
174
+ if (!p) return;
175
+ // the box's two white rows at its top, then the box under them
176
+ this.whiteLines(p.rows[0]);
177
+ this.fill(p.rows[0] + 2, p.rows[1], p.fill);
178
+ for (const y of p.more ?? []) this.whiteLines(y);
179
+ Object.assign(this.pen, { paper: p.paper, ink: p.ink, centre: true, spacing: 1, gap: p.gap, y: p.y });
180
+ }
181
+ // $E000, which PART3 writes: rows 19 to 115 moved two bytes to the left in each half of the
182
+ // screen, four bytes of it, seven dots. Bytes 3 to 38 of a half go to 1 to 36, the dots from 3.5
183
+ // to 129.5, and bytes 37 and 38 stay, so what is at the right is drawn again as it moves on.
184
+ // The view draws what is in those rows again, cut to them, over them, and moves it on seven dots
185
+ // each time; each time, the strip at the right that stays is drawn again where it came to.
186
+ scroll() {
187
+ const top = 19, bottom = 115, left = 3.5, right = 129.5, s = this.scrolling;
188
+ const clipped = (x0, x1, parent) => {
189
+ const id = 'scroll-' + (this.clipIds = (this.clipIds ?? 0) + 1);
190
+ // from half a pixel above row 19, where a smooth line along the row reaches
191
+ this.el('rect', { x: X(x0), y: Y(top) - 1, width: X(x1 - x0), height: Y(bottom - top + 1) + 1 }, this.el('clipPath', { id }, this.svg));
192
+ return this.el('g', { 'clip-path': `url(#${id})` }, parent);
193
+ };
194
+ const under = (x0, x1) => this.items.filter(i => i !== s?.item && i.top <= bottom && i.bottom >= top && i.left <= x1 && i.right >= x0);
195
+ if (s?.item.el.isConnected && this.items.at(-1) === s.item) {
196
+ // the strip, where it now is in what moves: seven dots on from where it was last time
197
+ const tile = clipped(right, right + 7, this.el('g', { transform: `translate(${X(7 * s.n)} 0)` }, s.moved));
198
+ for (const i of under(right, right + 7)) tile.append(i.el.cloneNode(true));
199
+ s.n++;
200
+ s.moved.setAttribute('transform', `translate(${X(-7 * s.n)} 0)`);
201
+ return;
202
+ }
203
+ const g = clipped(left, right), moved = this.el('g', { transform: `translate(${X(-7)} 0)` }, g);
204
+ // under it all, the window's color, wide enough to lie under the strips drawn as it moves on
205
+ this.rect(left, top, 140 * 8, bottom - top + 1, this.window.color, moved);
206
+ for (const i of under(left, 139)) moved.append(i.el.cloneNode(true));
207
+ this.add(top, bottom, g, { left: 4, right: 129 });
208
+ this.scrolling = { item: this.items.at(-1), moved, n: 1 };
209
+ }
210
+ // Nature Park's PART3 at $9796: dots `left` to `right` of rows `top` to `bottom` copied `by`
211
+ // rows down. As the scroll, the view draws what is in those rows again, cut to where they go and
212
+ // moved down; at each pass after, it moves on, and the rows at the top that stay are drawn again
213
+ // where they come to.
214
+ 'shift-down'({ left, right, top, bottom, by }) {
215
+ const s = this.shifting, cut = (y0, y1, parent) => {
216
+ const id = 'shift-' + (this.clipIds = (this.clipIds ?? 0) + 1);
217
+ this.el('rect', { x: X(left), y: Y(y0), width: X(right - left + 1), height: Y(y1 - y0) }, this.el('clipPath', { id }, this.svg));
218
+ return this.el('g', { 'clip-path': `url(#${id})` }, parent);
219
+ };
220
+ const under = (y0, y1) => this.items.filter(i => i !== s?.item && i.top <= y1 && i.bottom >= y0 && i.left <= right && i.right >= left);
221
+ if (s?.item.el.isConnected && this.items.at(-1) === s.item) {
222
+ const tile = cut(top, top + by, this.el('g', { transform: `translate(0 ${Y(-by * s.n)})` }, s.moved));
223
+ for (const i of under(top, top + by - 1)) tile.append(i.el.cloneNode(true));
224
+ s.n++;
225
+ s.moved.setAttribute('transform', `translate(0 ${Y(by * s.n)})`);
226
+ return;
227
+ }
228
+ const g = cut(top + by, bottom + by + 1), moved = this.el('g', { transform: `translate(0 ${Y(by)})` }, g);
229
+ this.rect(left, top - by * 60, right - left + 1, bottom - top + 1 + by * 61, this.window.color, moved);
230
+ for (const i of under(top, bottom)) moved.append(i.el.cloneNode(true));
231
+ this.add(top + by, bottom + by, g, { left, right });
232
+ this.shifting = { item: this.items.at(-1), moved, n: 1 };
233
+ }
234
+
235
+ // A run of a row ($7947), or a dot ($5AF4), in a color. Runs one under another, the same across
236
+ // and in the same color, are one rectangle, which grows a row at a time and covers what lies
237
+ // wholly inside it: the racing car's square cleared, a row or a column at a time, takes the car.
238
+ paint(color, left, right, y) {
239
+ this.hide(left, right, y, y);
240
+ const last = this.painted;
241
+ if (last?.el.isConnected && last.color === color && last.left === left && last.right === right && last.bottom === y - 1) {
242
+ last.bottom = y; last.el.setAttribute('height', Y(y - last.top + 1));
243
+ const item = this.items.find(i => i.el === last.el);
244
+ this.items = this.items.filter(i => { if (i !== item && i.top >= last.top && i.bottom <= y && i.left >= left && i.right <= right) { i.el.remove(); return false; } return true; });
245
+ if (item) item.bottom = y;
246
+ return;
247
+ }
248
+ const el = this.add(y, y, this.rect(left, y, right - left + 1, 1, color, null), { left, right });
249
+ this.painted = { el, color, left, right, top: y, bottom: y };
250
+ }
251
+ span({ a, x, y, row }) { this.paint(a & 15, x, y, row); }
252
+ dot({ a, x, y }) { this.paint(a & 15, x, x, y); }
253
+ 'white-lines'({ y }) { this.whiteLines(y); }
254
+ 'fill-rows'({ a, x, y }) { this.fill(y, y + x - 1, a); }
255
+
256
+ picture({ words }) {
257
+ const shapes = this.pictures.get(words.toUpperCase().replace(/_/g, ' '));
258
+ if (!shapes) return; // part1.pic and the like: the loader's own names for a file of pictures
259
+ // drawn smooth, its edges may reach past the window's rows, which the original's never do
260
+ const id = 'window-' + this.window.bottom;
261
+ this.clips ??= new Set();
262
+ if (!this.clips.has(id)) this.clips.add(id), this.el('rect', { x: 0, y: 0, width: X(140), height: Y(this.window.bottom) }, this.el('clipPath', { id }, this.svg));
263
+ const g = this.el('g', { 'clip-path': `url(#${id})` }), scaled = this.el('g', { transform: 'scale(4 2)' }, g);
264
+ scaled.innerHTML = elements(shapes, { crisp: false });
265
+ this.add(1, this.window.bottom - 1, g, { covers: false, left: 1, right: 138 });
266
+ this.shapes = this.shapes.concat(shapes);
267
+ this.picturesShapes = shapes; // the last picture's, which a problem may draw again (PROB1's colouring)
268
+ }
269
+ objects({ table }) { this.table = parseInt(table.slice(1), 16); }
270
+ // how: 'whole' ($686D), 'keep' ($686F) or 'clear' ($6863), as @moonbase-machines/vector/objects.js and raster.js say
271
+ object({ a, x, y }, how = 'whole') {
272
+ const keep = how === 'keep', whole = how === 'whole';
273
+ const table = this.tables.get(this.table);
274
+ // Object 0 puts back the ground kept under the latest object drawn keeping it ($686F), which
275
+ // takes that object away. Once put back, the ground is put back again to no further effect.
276
+ if (a < 2) { if (this.kept?.isConnected) this.remove(this.kept); this.kept = null; return; }
277
+ const o = table?.find(o => o.number === a);
278
+ if (!o) return;
279
+ // the dots of the object's clear color are left out, so the screen shows through them, but for
280
+ // an object put down whole
281
+ const rows = o.rows.map(row => row.map(c => !whole && c === o.clear ? undefined : c));
282
+ const right = x + o.width - 1, bottom = y + o.height - 1;
283
+ // An object put down whole hides every dot under it. What it leaves showing of another put
284
+ // down whole is all of that one that stays: PROB6's car, drawn a dot on each time, leaves a
285
+ // column of the one before, as the original's screen does, and no hair of the rest at the
286
+ // edges of the page's pixels.
287
+ if (whole) this.hide(x, right, y, bottom);
288
+ const el = this.el('image', { href: this.image(rows), x: X(x), y: Y(y), width: X(o.width), height: Y(o.height), preserveAspectRatio: 'none', style: 'image-rendering: pixelated' });
289
+ // put down whole, it hides for good whatever lies wholly inside it, as the crane its rope's dots
290
+ this.add(y, bottom, el, { covers: whole, left: x, right });
291
+ if (keep) this.kept = el;
292
+ if (whole) this.items.at(-1).whole = { x, y, rows };
293
+ }
294
+ // The dots of objects put down whole that something opaque now covers, from column x0 to x1 and
295
+ // row y0 to y1, taken out of their pictures: what is left of each is all of it that shows, and
296
+ // none of what is covered shows at the edges of the page's pixels.
297
+ hide(x0, x1, y0, y1) {
298
+ for (const i of [...this.items]) {
299
+ const w = i.whole;
300
+ if (!w || w.x > x1 || w.x + w.rows[0].length - 1 < x0 || w.y > y1 || w.y + w.rows.length - 1 < y0) continue;
301
+ let changed = false;
302
+ const left = w.rows.map((row, dy) => row.map((c, dx) => {
303
+ const px = w.x + dx, py = w.y + dy;
304
+ if (c !== undefined && px >= x0 && px <= x1 && py >= y0 && py <= y1) { changed = true; return undefined; }
305
+ return c;
306
+ }));
307
+ if (!changed) continue;
308
+ if (left.every(row => row.every(c => c === undefined))) { this.remove(i.el); continue; }
309
+ i.el.setAttribute('href', this.image(left)); w.rows = left;
310
+ }
311
+ }
312
+ // rows of colors as a picture, undefined where nothing is drawn
313
+ image(rows) {
314
+ const canvas = document.createElement('canvas'); canvas.width = rows[0].length; canvas.height = rows.length;
315
+ const cx = canvas.getContext('2d'), img = cx.createImageData(canvas.width, canvas.height);
316
+ rows.forEach((row, dy) => row.forEach((c, dx) => {
317
+ if (c === undefined) return;
318
+ const [r, g, b] = COLORS[c]; img.data.set([r, g, b, 255], (dy * canvas.width + dx) * 4);
319
+ }));
320
+ cx.putImageData(img, 0, 0);
321
+ return canvas.toDataURL();
322
+ }
323
+ 'object-keep'(e) { this.object(e, 'keep'); }
324
+ 'object-mode'(e) { this.object(e, 'clear'); }
325
+ // $76ED and $76F3: a key cap at the pen, which is left past it (vector.js)
326
+ 'key-at'({ a, x, y }) { this.pen.x = x; this.pen.y = y; this.key({ a }); }
327
+ key({ a }) {
328
+ const w = width(a);
329
+ this.cap(a, this.pen.x, this.pen.y, this.layer, true, undefined, Math.max(4, w) + 4);
330
+ this.pen.x += 1 + (w < 4 ? 4 - w : 0) + 1 + w + 3;
331
+ }
332
+
333
+ // a key cap: outlined in blue, filled magenta, its letter white
334
+ cap(ch, x, y, parent, add, key, wide) {
335
+ const label = { '\r': 'RETURN', '\x7f': 'DELETE' }[ch] ?? ch;
336
+ const w = wide ?? (label.length > 1 ? 32 : 7);
337
+ const g = this.el('g', key !== undefined ? { class: 'key', 'data-key': key } : {});
338
+ this.el('rect', { x: X(x), y: Y(y - 2), width: X(w), height: Y(11), rx: 3, fill: rgb(1), stroke: rgb(13), 'stroke-width': 3 }, g);
339
+ if (this.oldStyle) {
340
+ // the engine's cap: the glyph a dot in from the left, or RETURN and DELETE spelt out
341
+ let tw = 0; for (const c of label) tw += width(c) + 1;
342
+ this.glyphs(g, label, x + Math.max(1, Math.round((w - tw + 1) / 2)), y, 15);
343
+ } else {
344
+ const t = this.el('text', { x: X(x + w / 2), y: Y(y + 7), fill: rgb(15), 'font-family': SANS, 'font-size': label.length > 1 ? 13 : 16, 'font-weight': 600, 'text-anchor': 'middle' }, g);
345
+ t.textContent = this.modern(label);
346
+ }
347
+ if (add) { this.add(y - 2, y + 8, g, { covers: false, left: x, right: x + w }); this.items.at(-1).cap = true; } else parent.append(g);
348
+ return g;
349
+ }
350
+
351
+ // Text, laid out as the engine lays it out (layout.js). A line is drawn a word at a time, each
352
+ // word where the engine's font puts it and as wide, in dots, as the engine made it: the words
353
+ // sit where the original's did, and read sharper.
354
+ text({ words }) {
355
+ if (this.textScreen.getAttribute('display') !== 'none') {
356
+ for (const c of textLayout(words, this.textPen)) if (this.textCells[c.row]?.[c.col]) this.textCell(c.row, c.col, c.ch, c.inverse);
357
+ return;
358
+ }
359
+ for (const line of layout(words, this.pen)) {
360
+ const { top, tall } = line, size = tall ? 32 : 19;
361
+ // the paper of the line's glyphs' cells, first, as the engine paints it: a text written
362
+ // over another takes it away
363
+ const cells = line.chars.filter(c => c.ch.charCodeAt(0) >= 0x20);
364
+ if (cells.length) {
365
+ const from = Math.min(...cells.map(c => c.x)), to = Math.max(...cells.map(c => c.x + width(c.ch))) - 1, rows = Math.min(9, line.height ?? 9) * (tall ? 2 : 1);
366
+ this.add(top, top + rows - 1, this.rect(from, top, to - from + 1, rows, line.paper, null), { left: from, right: to });
367
+ }
368
+ const g = this.el('g', { 'font-family': SANS, 'font-size': size, 'font-weight': tall ? 700 : 500 });
369
+ let word = null, left = 139, right = 0;
370
+ const end = () => {
371
+ if (!word) return;
372
+ left = Math.min(left, word.x); right = Math.max(right, word.x + word.width);
373
+ if (this.oldStyle) { this.glyphs(g, word.chars, word.x, top, line.ink, tall, line.paper); word = null; return; }
374
+ const el = this.el('text', { y: Y(top + (tall ? 15 : 7)), 'data-x': X(word.x), 'data-width': X(word.width) }, g);
375
+ // the part of a line before its first color is drawn in the ink the whole line ends with
376
+ for (const c of word.chars) { const span = this.el('tspan', { fill: rgb(c.ink ?? line.ink) }, el); span.textContent = this.modern(c.ch); }
377
+ word = null;
378
+ };
379
+ for (const c of line.chars) {
380
+ if (c.ch.charCodeAt(0) <= 0x20) { end(); continue; }
381
+ word ??= { x: c.x, width: 0, chars: [] };
382
+ word.chars.push(c); word.width = c.x + width(c.ch) - word.x;
383
+ }
384
+ end();
385
+ if (g.childNodes.length) { this.add(top, top + (tall ? 16 : 8), g, { covers: false, left, right }); this.fit(g); }
386
+ }
387
+ }
388
+ // Each word of a line, drawn at x 0, is measured once it is on the page and then moved to its
389
+ // place and scaled to the width the engine gave it. Browsers differ in how they stretch text to
390
+ // a length, and a measured scale looks the same in all of them.
391
+ fit(g) {
392
+ for (const el of g.querySelectorAll('text[data-width]')) {
393
+ const natural = el.getComputedTextLength();
394
+ if (natural > 0) el.setAttribute('transform', `translate(${el.dataset.x} 0) scale(${el.dataset.width / natural} 1)`);
395
+ }
396
+ }
397
+ // The old style: the engine's own glyphs, each set dot a square, over the glyph's cell in the
398
+ // paper color. `chars` is a string, or the characters of a line with the ink each was given
399
+ // (the part of a line before its first color takes the ink the line ends with). Returns the
400
+ // dot after the last glyph.
401
+ glyphs(parent, chars, x, y, ink, tall = false, paper) {
402
+ const list = typeof chars === 'string' ? [...chars].map(ch => ({ ch })) : chars, h = tall ? 2 : 1;
403
+ const paths = new Map();
404
+ for (const c of list) {
405
+ const w = width(c.ch), colour = c.ink ?? ink;
406
+ if (paper !== undefined) this.rect(x, y, w, 9 * h, paper, parent);
407
+ let d = paths.get(colour) ?? '';
408
+ for (const [gx, gy] of dots(c.ch)) d += `M${X(x + gx)} ${Y(y + gy * h)}h4v${2 * h}h-4z`;
409
+ paths.set(colour, d);
410
+ x += w + 1;
411
+ }
412
+ for (const [colour, d] of paths) if (d) this.el('path', { d, fill: rgb(colour) }, parent);
413
+ return x;
414
+ }
415
+
416
+ // plain words at dot x and row y, in the engine's font widths with spacing 1, fitted as a text's
417
+ write(parent, text, x, y, ink = 15) {
418
+ if (this.oldStyle) return this.glyphs(parent, text, x, y, ink);
419
+ const g = this.el('g', { 'font-family': SANS, 'font-size': 19, 'font-weight': 500, fill: rgb(ink) }, parent);
420
+ for (const part of text.split(' ')) {
421
+ if (part) {
422
+ let w = 0; for (const ch of part) w += width(ch) + 1;
423
+ const el = this.el('text', { y: Y(y + 7), 'data-x': X(x), 'data-width': X(w - 1) }, g);
424
+ el.textContent = this.modern(part);
425
+ x += w;
426
+ }
427
+ x += width(' ') + 1;
428
+ }
429
+ this.fit(g);
430
+ return x;
431
+ }
432
+
433
+
434
+ line({ x0, x1, y0, y1, color }) {
435
+ this.add(Math.min(y0, y1), Math.max(y0, y1), this.el('line', { x1: X(x0 + 0.5), y1: Y(y0 + 0.5), x2: X(x1 + 0.5), y2: Y(y1 + 0.5), stroke: rgb(color), 'stroke-width': 3, 'stroke-linecap': 'round' }), { covers: false, left: Math.min(x0, x1), right: Math.max(x0, x1) });
436
+ }
437
+ // $67B9 fills from column x to x + width, both ends, down `height` rows; $677E draws its four
438
+ // lines from corner to corner, x to x + width and y to y + height
439
+ rectangle({ a, x, y, width, height }) { this.solid(a, x, y, width + 1, height); }
440
+ 'rectangle-outline'({ a, x, y, width, height }) {
441
+ const w = width + 1, h = height + 1;
442
+ this.add(y, y + h - 1, this.el('rect', { x: X(x) + 2, y: Y(y) + 1, width: X(w) - 4, height: Y(h) - 2, fill: 'none', stroke: rgb(a), 'stroke-width': 3 }), { covers: false, left: x, right: x + w - 1 });
443
+ }
444
+ // a rectangle w dots across and h down, filled
445
+ solid(a, x, y, w, h) { this.add(y, y + h - 1, this.rect(x, y, w, h, a, null), { left: x, right: x + w - 1 }); }
446
+
447
+ // what the view shows while the engine waits: the space bar's button, or the keys it asks for
448
+ prompts() {
449
+ this.prompt.replaceChildren();
450
+ const e = this.engine, step = e.waitingOn;
451
+ if (!step) return;
452
+ const name = step.words?.[0] ?? step.name;
453
+ if (name === 'wait' || name === 'wait-erase' || name === 'wait-or-escape') {
454
+ const y = (e.mem[this.vars.layout] & 15) === 1 ? 178 : 170;
455
+ const g = this.el('g', { class: 'key', 'data-key': 32 }, this.prompt);
456
+ this.el('rect', { x: X(46), y: Y(y), width: X(47), height: Y(11), rx: 4, fill: rgb(1), stroke: rgb(13), 'stroke-width': 3 }, g);
457
+ if (this.oldStyle) this.glyphs(g, 'SPACE BAR', 48, y + 2, 15);
458
+ else { const t = this.el('text', { x: X(69.5), y: Y(y + 8), fill: rgb(15), 'font-family': SANS, 'font-size': 15, 'font-weight': 600, 'text-anchor': 'middle' }, g); t.textContent = 'SPACE BAR'; }
459
+ this.spaceBar = { y, erase: name === 'wait-erase', el: g };
460
+ } else if (name === 'ask' || name === 'ask-mode' || name === 'ask-escape') {
461
+ this.rect(1, 162, 138, 28, 5, this.prompt); // box 2 inside its white edges, as panel 5 clears it
462
+ const mode = e.askMode, keys = [];
463
+ if (mode === 0x80) ARROWS.forEach((a, i) => keys.push([a, [8, 21, 10, 11][i]]));
464
+ else if (mode >= 0xb1 && mode < 0xba) for (let k = 0x31; k <= (mode & 0x7f); k++) keys.push([String.fromCharCode(k), k]);
465
+ else for (let k = 0x41; k <= (mode & 0x5f); k++) keys.push([String.fromCharCode(k), k]);
466
+ const label = (s, x, y) => this.write(this.prompt, s, x, y);
467
+ if (e.asked === null) {
468
+ label('Press', 4, 166);
469
+ keys.forEach(([s, k], i) => { if (i === keys.length - 1 && mode !== 0x80 && keys.length > 1) label('or', 31 + 10 * i, 166); this.cap(s, 31 + 10 * i + (i === keys.length - 1 && mode !== 0x80 && keys.length > 1 ? 10 : 0), 166, this.prompt, false, k); });
470
+ if (mode === 0x80) [[58, 62], [74, 62], [66, 71], [66, 53]].forEach(([x, y], i) => this.cap(ARROWS[i], x, y, this.prompt, false, keys[i][1]));
471
+ } else {
472
+ const [s] = keys[e.asked.place];
473
+ this.cap(s, 31 + 10 * e.asked.place, 166, this.prompt, false); // the chosen key stays, the rest of its row goes
474
+ label('Press', 4, 179); this.cap('\r', 31, 179, this.prompt, false, 13); label('or', 67, 179); this.cap('\x7f', 78, 179, this.prompt, false, 127);
475
+ if (mode === 0x80) this.cap(ARROWS[e.asked.place], [58, 74, 66, 66][e.asked.place], [62, 62, 71, 53][e.asked.place], this.prompt, false);
476
+ }
477
+ }
478
+ }
479
+ }
@@ -0,0 +1,65 @@
1
+ // The player's disk (disk/disk.js) on the memory backend, as the browser's IndexedDB one keeps it:
2
+ // files written, read at once, listed newest first, removed, and there again when the disk is opened
3
+ // again; and the Commodore 64's drive on it (@moonbase-machines/c64/saves.js): a file the game saves is kept, and
4
+ // in the drive the next time.
5
+ //
6
+ // node --test test/*.test.js
7
+ import { test } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import { openDisk, textBytes, bytesText } from '@moonbase-machines/player/disk/disk.js';
10
+ import { memory } from '@moonbase-machines/player/disk/memory.js';
11
+ import { Drive } from '@moonbase-machines/c64/drive.js';
12
+ import { keepDrive } from '@moonbase-machines/c64/saves.js';
13
+ import { make } from '@moonbase-machines/c64/d64.js';
14
+
15
+ test('a file written is read at once, and kept for the next time the disk is opened', async () => {
16
+ const backend = memory();
17
+ const a = await openDisk('title/one', { backend });
18
+ assert.deepEqual(a.list(), []);
19
+ a.write('STORY', [1, 2, 3], { kind: 'PRG' });
20
+ assert.deepEqual([...a.read('STORY')], [1, 2, 3], 'read before it is stored');
21
+ await a.written();
22
+ const b = await openDisk('title/one', { backend });
23
+ assert.deepEqual([...b.read('STORY')], [1, 2, 3]);
24
+ assert.deepEqual(b.list().map(f => [f.name, f.size, f.meta]), [['STORY', 3, { kind: 'PRG' }]]);
25
+ assert.equal(b.read('NONE'), null);
26
+ });
27
+
28
+ test('each disk its own; the newest first; a file removed is gone', async () => {
29
+ const backend = memory();
30
+ const d = await openDisk('title/two', { backend }), other = await openDisk('title/three', { backend });
31
+ d.write('first', textBytes('one'));
32
+ await new Promise(r => setTimeout(r, 5));
33
+ d.write('second', textBytes('two'));
34
+ other.write('first', [9]);
35
+ assert.deepEqual(d.list().map(f => f.name), ['second', 'first']);
36
+ assert.equal(bytesText(d.read('second')), 'two');
37
+ await d.remove('first');
38
+ const again = await openDisk('title/two', { backend });
39
+ assert.deepEqual(again.list().map(f => f.name), ['second']);
40
+ assert.deepEqual([...(await openDisk('title/three', { backend })).read('first')], [9]);
41
+ });
42
+
43
+ test('a copy written is the disk\'s own: changing the bytes after does not change it', async () => {
44
+ const d = await openDisk('t', { backend: memory() });
45
+ const bytes = new Uint8Array([1, 2]);
46
+ d.write('f', bytes); bytes[0] = 7;
47
+ assert.equal(d.read('f')[0], 1);
48
+ });
49
+
50
+ test('a backend that keeps nothing says so', async () => {
51
+ assert.equal((await openDisk('t', { backend: memory({ kept: false }) })).kept, false);
52
+ });
53
+
54
+ test('a C64 game\'s SAVE is kept on the player\'s disk, and in the drive the next time', async () => {
55
+ const backend = memory();
56
+ const image = make({ name: 'game', id: '01', files: [] });
57
+ const disk = await openDisk('title/one', { backend });
58
+ const one = new Drive(image);
59
+ keepDrive(one, disk);
60
+ one.save(Uint8Array.from('TALE', c => c.charCodeAt(0)), Uint8Array.from([0x00, 0xc0, 42]));
61
+ await disk.written();
62
+ const two = new Drive(image);
63
+ keepDrive(two, await openDisk('title/one', { backend }));
64
+ assert.deepEqual([...two.load(Uint8Array.from('TALE', c => c.charCodeAt(0)))], [0x00, 0xc0, 42]);
65
+ });
@@ -0,0 +1,56 @@
1
+ // A title loaded from its folder, as a page gives the player its address: its title.js, the modules it names, its
2
+ // view's drawing, its compiled scripts or its scenes, its game's data; a title whose engine is its own, its module
3
+ // loaded and no scripts; a vector title's code files at the addresses its map gives. From files the tests write.
4
+ //
5
+ // node --test test/*.test.js
6
+ import { test } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { tmpdir } from 'node:os';
11
+ import { pathToFileURL } from 'node:url';
12
+ import { loadTitle } from '@moonbase-machines/player/remake/load.js';
13
+ import { codeFiles } from '@moonbase-machines/player/remake/vector.js';
14
+
15
+ // a title's folder and its data, written: each file a name and its text
16
+ function folder(files) {
17
+ const at = mkdtempSync(join(tmpdir(), 'title-'));
18
+ for (const [name, text] of Object.entries(files)) { mkdirSync(join(at, name, '..'), { recursive: true }); writeFileSync(join(at, name), text); }
19
+ return pathToFileURL(at + '/');
20
+ }
21
+
22
+ test('a title from its folder: its title.js, its modules, its view, its compiled scripts and its data', async () => {
23
+ const at = folder({
24
+ 'title/title.js': "export default { slug: 'one', engine: 'card', scripts: ['start'], start: 'start', modules: ['problems'], view: true, map: { routines: { 0x800: 'char' } } };",
25
+ 'title/problems.js': 'export default { 1: "the first" };',
26
+ 'title/view.js': 'export default { char() {} };',
27
+ 'title/compiled/start.js': 'export default "compiled";',
28
+ 'title/start.scene': 'the scene',
29
+ 'one.json': JSON.stringify({ kind: 'dos33', saves: { volume: 'DISK', dos: 'dos33' }, volumes: { DISK: {} } }),
30
+ });
31
+ const { title, scripts, disk } = await loadTitle(new URL('title/', at), { data: new URL('one.json', at) });
32
+ assert.equal(title.slug, 'one');
33
+ assert.deepEqual(title.map.routines, { 0x800: 'char' }, 'its map, its own');
34
+ assert.deepEqual(title.problems, { 1: 'the first' });
35
+ assert.equal(typeof title.viewSteps.char, 'function', 'its view\'s drawing');
36
+ assert.deepEqual(scripts, { start: 'compiled' });
37
+ assert.deepEqual(disk.saves, { volume: 'DISK', dos: 'dos33' }, 'its data, its saves with it');
38
+ const raw = await loadTitle(new URL('title/', at), { data: new URL('one.json', at), scenes: true });
39
+ assert.deepEqual(raw.scripts, { start: 'the scene' }, 'or its scenes themselves');
40
+ });
41
+
42
+ test('a title whose engine is its own: its module loaded, to play it, no scripts and no data read', async () => {
43
+ const at = folder({
44
+ 'title/title.js': "export default { slug: 'two', engine: 'game', style: 'two.css' };",
45
+ 'title/game.js': 'export async function play(game) { game.played = true; }',
46
+ });
47
+ const { title, scripts, disk } = await loadTitle(new URL('title/', at), { data: new URL('data/', at) });
48
+ assert.equal(typeof title.own.play, 'function');
49
+ assert.deepEqual([scripts, disk], [{}, null]);
50
+ });
51
+
52
+ test('a vector title\'s code files at the addresses its map gives, else its catalog\'s', () => {
53
+ const volume = { PART0: { aux: 0x4000, data: btoa('\x01') }, PART1: { aux: 0x9000, data: btoa('\x02') }, PROB1: { aux: 0, data: btoa('\x03') }, PICS1: { aux: 0, data: '' } };
54
+ assert.deepEqual(codeFiles({ loads: { PART0: 0x4000, PART: 0x8000, PROB: 0x0800 } }, volume).map(f => [f.name, f.origin]), [['PART0', 0x4000], ['PART1', 0x8000], ['PROB1', 0x0800]]);
55
+ assert.deepEqual(codeFiles(undefined, volume).map(f => f.origin), [0x4000, 0x9000, 0]);
56
+ });