@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/LICENSE +21 -0
- package/README.md +40 -2
- package/audio.js +54 -0
- package/bin/moonbase-compile.mjs +23 -0
- package/bin/moonbase-play.mjs +81 -0
- package/data.js +39 -0
- package/disk/dialogs.js +101 -0
- package/disk/disk.js +57 -0
- package/disk/local.js +28 -0
- package/disk/memory.js +13 -0
- package/dist/moonbase-player.js +33 -0
- package/dist/moonbase-player.js.map +7 -0
- package/index.js +28 -0
- package/modal.js +68 -0
- package/original.js +200 -0
- package/package.json +25 -4
- package/parts.js +33 -0
- package/player.css +48 -0
- package/remake/c64-view.js +30 -0
- package/remake/card-view.js +53 -0
- package/remake/card.js +526 -0
- package/remake/cards.js +47 -0
- package/remake/compile.js +179 -0
- package/remake/engine.js +461 -0
- package/remake/events.js +79 -0
- package/remake/layout.js +144 -0
- package/remake/lift.js +197 -0
- package/remake/load.js +55 -0
- package/remake/main.js +51 -0
- package/remake/problems.js +43 -0
- package/remake/raster.js +72 -0
- package/remake/scenes.js +243 -0
- package/remake/script.js +60 -0
- package/remake/vector.js +363 -0
- package/remake/view.js +479 -0
- package/test/disk.test.js +65 -0
- package/test/load.test.js +56 -0
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
|
+
});
|