@conduction/docusaurus-preset 3.28.0 → 3.31.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/package.json +4 -4
- package/src/components/AppMock/AppMock.jsx +16 -0
- package/src/components/AppMock/variants/AppVersionsMock.jsx +83 -0
- package/src/components/AppMock/variants/DoriathMock.jsx +79 -0
- package/src/components/AppMock/variants/HermiqMock.jsx +71 -0
- package/src/components/AppMock/variants/HrmqMock.jsx +77 -0
- package/src/components/AppMock/variants/PlanixMock.jsx +80 -0
- package/src/components/AppMock/variants/PortaliqMock.jsx +63 -0
- package/src/components/AppMock/variants/ScholiqMock.jsx +74 -0
- package/src/components/AppMock/variants/ShillinqMock.jsx +90 -0
- package/src/components/CookieCli/CookieCli.jsx +197 -24
- package/src/components/CookieCli/CookieCli.module.css +139 -1
- package/src/components/CookieCli/shell.js +161 -0
- package/src/components/CookieCli/spaceInvaders.js +359 -0
- package/src/components/CookieCli/spaceInvaders.test.js +112 -0
- package/src/data/app-glyphs.json +4 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conduction Space Invaders — the game behind `game.exe` in the cookie CLI.
|
|
3
|
+
*
|
|
4
|
+
* Ported from the kit specimen at preview/cookie-cli.html, where this had
|
|
5
|
+
* lived since the component was designed. The React <CookieCli /> shipped
|
|
6
|
+
* without it for a while, which meant GameModal advertised a game
|
|
7
|
+
* ("Hex-vaders · cookie CLI") that no site actually had.
|
|
8
|
+
*
|
|
9
|
+
* Kept as a plain class rather than React state on purpose. It ticks every
|
|
10
|
+
* 90ms and repaints a 48x14 character grid, which is 672 cells per frame,
|
|
11
|
+
* eleven times a second. Reconciling that through React would do a great
|
|
12
|
+
* deal of work to arrive at the same string. The class builds the frame as
|
|
13
|
+
* HTML and hands it to the caller, which writes it to a ref.
|
|
14
|
+
*
|
|
15
|
+
* Cobalt UFOs, KNVB-orange lasers, per the kit.
|
|
16
|
+
*
|
|
17
|
+
* The engine owns no DOM and reads no globals. It reports frames through
|
|
18
|
+
* `onFrame` and asks to be dismissed through `onExit`, so the React
|
|
19
|
+
* component stays the only thing that touches the document.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const COLS = 48;
|
|
23
|
+
const ROWS = 14;
|
|
24
|
+
|
|
25
|
+
/* The event the shared GameModal listens for. Named `connext:` because the
|
|
26
|
+
modal has used that prefix since the ConNext era; it is a brand-internal
|
|
27
|
+
identifier kept for compatibility, not a live brand reference. */
|
|
28
|
+
const GAME_END_EVENT = 'connext:gameend';
|
|
29
|
+
const GAME_REPLAY_EVENT = 'connext:gamereplay';
|
|
30
|
+
const GAME_ID = 'invaders';
|
|
31
|
+
|
|
32
|
+
function escapeHtml(s) {
|
|
33
|
+
return String(s).replace(/[&<>"']/g, (c) => ({
|
|
34
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
35
|
+
}[c]));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default class SpaceInvaders {
|
|
39
|
+
/**
|
|
40
|
+
* @param {object} opts
|
|
41
|
+
* @param {function} opts.onFrame - called with {screen, hud, footer} HTML each repaint
|
|
42
|
+
* @param {function} opts.onExit - called when the player quits (ESC / Q)
|
|
43
|
+
*/
|
|
44
|
+
constructor({onFrame, onExit} = {}) {
|
|
45
|
+
this.onFrame = typeof onFrame === 'function' ? onFrame : () => {};
|
|
46
|
+
this.onExit = typeof onExit === 'function' ? onExit : () => {};
|
|
47
|
+
|
|
48
|
+
this.score = 0;
|
|
49
|
+
this.wave = 1;
|
|
50
|
+
this.hits = 0; // bombs that landed on you; costs nothing but pride
|
|
51
|
+
this.cleared = 0; // waves shot down
|
|
52
|
+
this.landed = 0; // waves that reached the floor
|
|
53
|
+
this.hitFlash = 0; // ticks left of the impact glyph
|
|
54
|
+
this.paused = false;
|
|
55
|
+
this.player = {x: Math.floor(COLS / 2) - 1};
|
|
56
|
+
this.lasers = [];
|
|
57
|
+
this.bombs = [];
|
|
58
|
+
this.ufos = [];
|
|
59
|
+
this.ufoDir = 1;
|
|
60
|
+
this.ufoSpeed = 9;
|
|
61
|
+
this.tickCount = 0;
|
|
62
|
+
this.graceTicks = 18; // ~1.6s before bombs start dropping
|
|
63
|
+
|
|
64
|
+
this.onReplay = (e) => {
|
|
65
|
+
if (e.detail && e.detail.id === GAME_ID) this.restart();
|
|
66
|
+
};
|
|
67
|
+
if (typeof window !== 'undefined') {
|
|
68
|
+
window.addEventListener(GAME_REPLAY_EVENT, this.onReplay);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
this.spawnUfos();
|
|
72
|
+
this.interval = setInterval(() => this.tick(), 90);
|
|
73
|
+
this.render();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
spawnUfos() {
|
|
77
|
+
this.ufos = [];
|
|
78
|
+
const rows = 3, cols = 6;
|
|
79
|
+
const spacing = 7;
|
|
80
|
+
const xStart = Math.floor((COLS - (cols - 1) * spacing - 3) / 2);
|
|
81
|
+
for (let r = 0; r < rows; r++) {
|
|
82
|
+
for (let c = 0; c < cols; c++) {
|
|
83
|
+
this.ufos.push({x: xStart + c * spacing, y: 1 + r * 2, alive: true, exploding: 0, row: r});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
onKey(e) {
|
|
89
|
+
/* The old game-over branch is gone with game over itself. [R] used to
|
|
90
|
+
live only in that branch, which would have left it dead: reachable
|
|
91
|
+
by a key hint that never appeared, on a screen that never showed.
|
|
92
|
+
It now restarts at any time. */
|
|
93
|
+
switch (e.key) {
|
|
94
|
+
case 'r': case 'R':
|
|
95
|
+
this.restart(); e.preventDefault(); return;
|
|
96
|
+
default: break;
|
|
97
|
+
}
|
|
98
|
+
switch (e.key) {
|
|
99
|
+
case 'ArrowLeft': case 'a': case 'A':
|
|
100
|
+
this.player.x = Math.max(0, this.player.x - 2);
|
|
101
|
+
e.preventDefault(); break;
|
|
102
|
+
case 'ArrowRight': case 'd': case 'D':
|
|
103
|
+
this.player.x = Math.min(COLS - 3, this.player.x + 2);
|
|
104
|
+
e.preventDefault(); break;
|
|
105
|
+
case ' ': case 'ArrowUp': case 'w': case 'W':
|
|
106
|
+
this.fire(); e.preventDefault(); break;
|
|
107
|
+
case 'Escape': case 'q': case 'Q':
|
|
108
|
+
this.quit(); e.preventDefault(); break;
|
|
109
|
+
case 'p': case 'P':
|
|
110
|
+
this.paused = !this.paused; e.preventDefault(); this.render(); break;
|
|
111
|
+
default: break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
fire() {
|
|
116
|
+
if (this.lasers.length >= 3) return;
|
|
117
|
+
this.lasers.push({x: this.player.x + 1, y: ROWS - 2});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
quit() {
|
|
121
|
+
this.stop();
|
|
122
|
+
this.onExit();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
stop() {
|
|
126
|
+
clearInterval(this.interval);
|
|
127
|
+
this.interval = null;
|
|
128
|
+
if (typeof window !== 'undefined') {
|
|
129
|
+
window.removeEventListener(GAME_REPLAY_EVENT, this.onReplay);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
restart() {
|
|
134
|
+
this.score = 0;
|
|
135
|
+
this.wave = 1;
|
|
136
|
+
this.hits = 0;
|
|
137
|
+
this.cleared = 0;
|
|
138
|
+
this.landed = 0;
|
|
139
|
+
this.hitFlash = 0;
|
|
140
|
+
this._endEmitted = false;
|
|
141
|
+
this.player = {x: Math.floor(COLS / 2) - 1};
|
|
142
|
+
this.lasers = [];
|
|
143
|
+
this.bombs = [];
|
|
144
|
+
this.ufoSpeed = 9;
|
|
145
|
+
this.tickCount = 0;
|
|
146
|
+
this.graceTicks = 18;
|
|
147
|
+
this.spawnUfos();
|
|
148
|
+
this.render();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/* Advance to a fresh wave, whether the last one was shot down or landed.
|
|
152
|
+
Clears bombs so the new wave does not open under incoming fire, and
|
|
153
|
+
restores the grace period so there is a beat before it resumes. */
|
|
154
|
+
nextWave() {
|
|
155
|
+
this.wave += 1;
|
|
156
|
+
this.spawnUfos();
|
|
157
|
+
this.ufoSpeed = Math.max(3, 9 - this.wave);
|
|
158
|
+
this.bombs = [];
|
|
159
|
+
this.graceTicks = 12;
|
|
160
|
+
this.render();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
tick() {
|
|
164
|
+
if (this.paused) return;
|
|
165
|
+
this.tickCount++;
|
|
166
|
+
|
|
167
|
+
// lasers up every tick
|
|
168
|
+
this.lasers.forEach((l) => { l.y -= 1; });
|
|
169
|
+
this.lasers = this.lasers.filter((l) => l.y >= 0);
|
|
170
|
+
|
|
171
|
+
// bombs down every 2 ticks
|
|
172
|
+
if (this.tickCount % 2 === 0) {
|
|
173
|
+
this.bombs.forEach((b) => { b.y += 1; });
|
|
174
|
+
this.bombs = this.bombs.filter((b) => b.y < ROWS);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// UFOs march
|
|
178
|
+
if (this.tickCount % this.ufoSpeed === 0) {
|
|
179
|
+
const alive = this.ufos.filter((u) => u.alive);
|
|
180
|
+
if (alive.length) {
|
|
181
|
+
const minX = Math.min(...alive.map((u) => u.x));
|
|
182
|
+
const maxX = Math.max(...alive.map((u) => u.x));
|
|
183
|
+
if (this.ufoDir === 1 && maxX + 3 >= COLS) {
|
|
184
|
+
this.ufoDir = -1;
|
|
185
|
+
alive.forEach((u) => { u.y += 1; });
|
|
186
|
+
} else if (this.ufoDir === -1 && minX <= 0) {
|
|
187
|
+
this.ufoDir = 1;
|
|
188
|
+
alive.forEach((u) => { u.y += 1; });
|
|
189
|
+
} else {
|
|
190
|
+
alive.forEach((u) => { u.x += this.ufoDir; });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// random bombs, after a short grace period so the player can settle in
|
|
196
|
+
if (this.graceTicks > 0) {
|
|
197
|
+
this.graceTicks--;
|
|
198
|
+
} else {
|
|
199
|
+
const aliveUfos = this.ufos.filter((u) => u.alive);
|
|
200
|
+
const bombChance = 0.035 + (this.wave - 1) * 0.01;
|
|
201
|
+
if (aliveUfos.length && this.bombs.length < 4 && Math.random() < bombChance) {
|
|
202
|
+
const u = aliveUfos[Math.floor(Math.random() * aliveUfos.length)];
|
|
203
|
+
this.bombs.push({x: u.x + 1, y: u.y + 1});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// laser vs ufo
|
|
208
|
+
this.lasers.forEach((l) => {
|
|
209
|
+
this.ufos.forEach((u) => {
|
|
210
|
+
if (!u.alive) return;
|
|
211
|
+
if (l.y === u.y && l.x >= u.x && l.x <= u.x + 2) {
|
|
212
|
+
u.alive = false;
|
|
213
|
+
u.exploding = 2;
|
|
214
|
+
l.dead = true;
|
|
215
|
+
this.score += 10 * (3 - u.row);
|
|
216
|
+
const remaining = this.ufos.filter((uu) => uu.alive).length;
|
|
217
|
+
this.ufoSpeed = Math.max(2, 8 - Math.floor((18 - remaining) / 3));
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
this.lasers = this.lasers.filter((l) => !l.dead);
|
|
222
|
+
|
|
223
|
+
this.ufos.forEach((u) => { if (u.exploding > 0) u.exploding -= 1; });
|
|
224
|
+
|
|
225
|
+
/* Bombs vs player. You cannot die here.
|
|
226
|
+
This is an easter egg in a cookie banner, not an arcade cabinet:
|
|
227
|
+
ending someone's game with GAME OVER while they were only trying to
|
|
228
|
+
set a cookie preference is a bad trade. A hit still lands and still
|
|
229
|
+
reads as a hit, it just costs a moment of shield flash rather than a
|
|
230
|
+
life. Bombs stay dangerous-looking and become harmless. */
|
|
231
|
+
this.bombs.forEach((b) => {
|
|
232
|
+
if (b.y === ROWS - 1 && b.x >= this.player.x && b.x <= this.player.x + 2) {
|
|
233
|
+
b.dead = true;
|
|
234
|
+
this.hitFlash = 4; // ticks the player renders as an impact
|
|
235
|
+
this.hits += 1;
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
this.bombs = this.bombs.filter((b) => !b.dead);
|
|
239
|
+
if (this.hitFlash > 0) this.hitFlash -= 1;
|
|
240
|
+
|
|
241
|
+
/* UFOs reaching the bottom used to end the game outright, regardless of
|
|
242
|
+
lives. Now the wave simply lands and a fresh one spawns, so the run
|
|
243
|
+
continues. Without this the one unavoidable death would still be
|
|
244
|
+
reachable by standing still. */
|
|
245
|
+
if (this.ufos.some((u) => u.alive && u.y >= ROWS - 1)) {
|
|
246
|
+
this.landed += 1;
|
|
247
|
+
this.nextWave();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// wave clear
|
|
252
|
+
if (this.ufos.every((u) => !u.alive && u.exploding === 0)) {
|
|
253
|
+
this.cleared += 1;
|
|
254
|
+
this.nextWave();
|
|
255
|
+
|
|
256
|
+
/* Tell the shared GameModal the game was found, so it records
|
|
257
|
+
discovery and score in its cross-site progress cookie.
|
|
258
|
+
This used to fire on game over. Since there is no game over any
|
|
259
|
+
more, clearing the first wave is the moment: without moving it the
|
|
260
|
+
event would never fire at all and the game would stay permanently
|
|
261
|
+
undiscovered in the modal, which is a silent regression rather than
|
|
262
|
+
a visible one. Once per run. */
|
|
263
|
+
if (!this._endEmitted && typeof window !== 'undefined') {
|
|
264
|
+
this._endEmitted = true;
|
|
265
|
+
window.dispatchEvent(new CustomEvent(GAME_END_EVENT, {
|
|
266
|
+
detail: {
|
|
267
|
+
id: GAME_ID,
|
|
268
|
+
won: true,
|
|
269
|
+
score: this.score,
|
|
270
|
+
summary: `${this.score} pts · wave ${this.wave}`,
|
|
271
|
+
},
|
|
272
|
+
}));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
this.render();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
render() {
|
|
280
|
+
const grid = [];
|
|
281
|
+
const meta = [];
|
|
282
|
+
for (let y = 0; y < ROWS; y++) {
|
|
283
|
+
grid.push(Array(COLS).fill(' '));
|
|
284
|
+
meta.push(Array(COLS).fill(''));
|
|
285
|
+
}
|
|
286
|
+
const set = (y, x, c, t) => {
|
|
287
|
+
if (y >= 0 && y < ROWS && x >= 0 && x < COLS) { grid[y][x] = c; meta[y][x] = t; }
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
this.ufos.forEach((u) => {
|
|
291
|
+
if (u.alive) {
|
|
292
|
+
set(u.y, u.x, '<', 'ufo'); set(u.y, u.x + 1, 'O', 'ufo'); set(u.y, u.x + 2, '>', 'ufo');
|
|
293
|
+
} else if (u.exploding > 0) {
|
|
294
|
+
set(u.y, u.x, '*', 'boom'); set(u.y, u.x + 1, '#', 'boom'); set(u.y, u.x + 2, '*', 'boom');
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
this.lasers.forEach((l) => set(l.y, l.x, '|', 'laser'));
|
|
299
|
+
this.bombs.forEach((b) => set(b.y, b.x, '*', 'bomb'));
|
|
300
|
+
|
|
301
|
+
/* The ship, or a brief impact burst when something just hit it. The
|
|
302
|
+
flash is the whole feedback for taking a hit now that it costs
|
|
303
|
+
nothing, so without it bombs would land with no effect at all and
|
|
304
|
+
the game would feel broken rather than forgiving. */
|
|
305
|
+
if (this.hitFlash > 0) {
|
|
306
|
+
set(ROWS - 1, this.player.x, '*', 'boom');
|
|
307
|
+
set(ROWS - 1, this.player.x + 1, '#', 'boom');
|
|
308
|
+
set(ROWS - 1, this.player.x + 2, '*', 'boom');
|
|
309
|
+
} else {
|
|
310
|
+
set(ROWS - 1, this.player.x, '<', 'player');
|
|
311
|
+
set(ROWS - 1, this.player.x + 1, 'A', 'player');
|
|
312
|
+
set(ROWS - 1, this.player.x + 2, '>', 'player');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/* Coalesce equal-styled runs into one span per run instead of one per
|
|
316
|
+
character. A row is 48 cells but usually only a handful of runs. */
|
|
317
|
+
let screen = '';
|
|
318
|
+
for (let y = 0; y < ROWS; y++) {
|
|
319
|
+
let line = '';
|
|
320
|
+
let runT = '', runS = '';
|
|
321
|
+
const flush = () => {
|
|
322
|
+
if (!runS) return;
|
|
323
|
+
line += runT ? `<span class="g-${runT}">${escapeHtml(runS)}</span>` : escapeHtml(runS);
|
|
324
|
+
runS = '';
|
|
325
|
+
};
|
|
326
|
+
for (let x = 0; x < COLS; x++) {
|
|
327
|
+
if (meta[y][x] === runT) runS += grid[y][x];
|
|
328
|
+
else { flush(); runT = meta[y][x]; runS = grid[y][x]; }
|
|
329
|
+
}
|
|
330
|
+
flush();
|
|
331
|
+
screen += `${line}\n`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/* LIVES is gone, because there is nothing to count down. SHIELD says
|
|
335
|
+
the same thing the three little ships used to say (you are fine)
|
|
336
|
+
without implying a budget that can run out. HITS is there so the
|
|
337
|
+
bombs still mean something. */
|
|
338
|
+
const hud = `
|
|
339
|
+
<span class="hud-label">SCORE</span> <span class="hud-val g-laser">${String(this.score).padStart(4, '0')}</span>
|
|
340
|
+
<span class="hud-label">SHIELD</span> <span class="hud-val g-player">${escapeHtml('∞')}</span>
|
|
341
|
+
<span class="hud-label">WAVE</span> <span class="hud-val">${this.wave}</span>
|
|
342
|
+
<span class="hud-label">HITS</span> <span class="hud-val">${this.hits}</span>
|
|
343
|
+
<span style="margin-left:auto" class="hud-label">conduction · space-invaders.exe</span>
|
|
344
|
+
`;
|
|
345
|
+
|
|
346
|
+
let footer;
|
|
347
|
+
if (this.paused) {
|
|
348
|
+
footer = '<span class="warn">-- paused -- press [P] to resume</span>';
|
|
349
|
+
} else if (this.graceTicks > 0) {
|
|
350
|
+
footer = '<span class="ok">-- READY --</span> <span class="cmt">defend the cookies. fire orange lasers at the cobalt UFOs.</span>';
|
|
351
|
+
} else {
|
|
352
|
+
footer = '<span class="cmt"># [← →] move [space] fire orange laser [P] pause [ESC] exit</span>';
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
this.onFrame({screen, hud, footer});
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export {COLS, ROWS, GAME_ID, GAME_END_EVENT};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one rule this game has: you cannot lose it.
|
|
3
|
+
*
|
|
4
|
+
* It is an easter egg inside a cookie banner. Someone opened that banner to
|
|
5
|
+
* set a preference, so ending their game with GAME OVER is a worse outcome
|
|
6
|
+
* than letting them play forever. Both original death paths are covered
|
|
7
|
+
* here, because they were separate: bombs draining lives, and the swarm
|
|
8
|
+
* reaching the floor, which ended the run outright no matter how many lives
|
|
9
|
+
* were left.
|
|
10
|
+
*
|
|
11
|
+
* These assertions were checked against the previous engine before being
|
|
12
|
+
* committed: nine of the ten fail on it. A test for the absence of
|
|
13
|
+
* behaviour is worth very little until you have watched it fail.
|
|
14
|
+
*
|
|
15
|
+
* Runs under `npm test` (node --test) with no DOM: the engine guards its
|
|
16
|
+
* window access, and stop() lets the tick be driven by hand instead of a
|
|
17
|
+
* timer.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import test from 'node:test';
|
|
21
|
+
import assert from 'node:assert/strict';
|
|
22
|
+
import SpaceInvaders, {ROWS} from './spaceInvaders.js';
|
|
23
|
+
|
|
24
|
+
function newGame() {
|
|
25
|
+
const frames = [];
|
|
26
|
+
const game = new SpaceInvaders({
|
|
27
|
+
onFrame: (f) => frames.push(f),
|
|
28
|
+
onExit: () => assert.fail('the game exited on its own'),
|
|
29
|
+
});
|
|
30
|
+
game.stop(); // drive tick() manually
|
|
31
|
+
return {game, frames};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test('bombs landing on the player never end the run', () => {
|
|
35
|
+
const {game} = newGame();
|
|
36
|
+
game.graceTicks = 0;
|
|
37
|
+
/* Drop each bomb one row above the player and tick twice, which is how a
|
|
38
|
+
bomb actually arrives: it descends every other tick, and the collision
|
|
39
|
+
check runs in the same tick as the move. Injecting one directly onto
|
|
40
|
+
the player's row instead tests nothing, because on a move tick it
|
|
41
|
+
advances to ROWS and is filtered as off-screen before the check. */
|
|
42
|
+
for (let i = 0; i < 6; i++) {
|
|
43
|
+
game.bombs.push({x: game.player.x + 1, y: ROWS - 2});
|
|
44
|
+
game.tick();
|
|
45
|
+
game.tick();
|
|
46
|
+
}
|
|
47
|
+
assert.ok(game.hits >= 6, `all six bombs should register a hit, got ${game.hits}`);
|
|
48
|
+
assert.equal(game.lives, undefined, 'there should be no lives counter to run out');
|
|
49
|
+
assert.equal(game.gameOver, undefined, 'there should be no game-over flag');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('the swarm reaching the floor rolls into a new wave', () => {
|
|
53
|
+
const {game} = newGame();
|
|
54
|
+
const before = game.wave;
|
|
55
|
+
game.ufos.forEach((u) => { u.alive = true; u.y = ROWS - 1; });
|
|
56
|
+
game.tick();
|
|
57
|
+
assert.equal(game.wave, before + 1, 'the wave should advance');
|
|
58
|
+
assert.equal(game.ufos.filter((u) => u.alive).length, 18, 'a fresh swarm should spawn');
|
|
59
|
+
assert.equal(game.landed, 1, 'the landing should be counted');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('clearing a wave advances it', () => {
|
|
63
|
+
const {game} = newGame();
|
|
64
|
+
const before = game.wave;
|
|
65
|
+
game.ufos.forEach((u) => { u.alive = false; u.exploding = 0; });
|
|
66
|
+
game.tick();
|
|
67
|
+
assert.equal(game.wave, before + 1);
|
|
68
|
+
assert.equal(game.cleared, 1);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('no frame ever renders a GAME OVER', () => {
|
|
72
|
+
const {game, frames} = newGame();
|
|
73
|
+
game.graceTicks = 0;
|
|
74
|
+
for (let i = 0; i < 40; i++) {
|
|
75
|
+
game.bombs.push({x: game.player.x + 1, y: ROWS - 1});
|
|
76
|
+
game.tick();
|
|
77
|
+
}
|
|
78
|
+
const bad = frames.filter((f) => /GAME OVER|YOU WIN/i.test(f.footer));
|
|
79
|
+
assert.equal(bad.length, 0, 'the footer should never announce an ending');
|
|
80
|
+
assert.ok(frames.at(-1).hud.includes('∞'), 'the HUD should show an infinite shield');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/* GameModal records discovery from `connext:gameend`. That used to fire on
|
|
84
|
+
game over, so removing game over would have silently stopped the game
|
|
85
|
+
from ever being marked as found. It now fires on the first wave cleared,
|
|
86
|
+
and this is the test that keeps it honest. */
|
|
87
|
+
test('clearing the first wave still reports the game as found', () => {
|
|
88
|
+
const events = [];
|
|
89
|
+
const priorWindow = globalThis.window;
|
|
90
|
+
globalThis.window = {
|
|
91
|
+
addEventListener() {}, removeEventListener() {},
|
|
92
|
+
dispatchEvent: (e) => { events.push(e); return true; },
|
|
93
|
+
};
|
|
94
|
+
globalThis.CustomEvent = globalThis.CustomEvent || class { constructor(type, init) { this.type = type; this.detail = init?.detail; } };
|
|
95
|
+
try {
|
|
96
|
+
const game = new SpaceInvaders({onFrame() {}, onExit() {}});
|
|
97
|
+
game.stop();
|
|
98
|
+
game.ufos.forEach((u) => { u.alive = false; u.exploding = 0; });
|
|
99
|
+
game.tick();
|
|
100
|
+
const end = events.filter((e) => e.type === 'connext:gameend');
|
|
101
|
+
assert.equal(end.length, 1, 'exactly one gameend should fire');
|
|
102
|
+
assert.equal(end[0].detail.id, 'invaders');
|
|
103
|
+
assert.equal(end[0].detail.won, true);
|
|
104
|
+
|
|
105
|
+
// and only once per run
|
|
106
|
+
game.ufos.forEach((u) => { u.alive = false; u.exploding = 0; });
|
|
107
|
+
game.tick();
|
|
108
|
+
assert.equal(events.filter((e) => e.type === 'connext:gameend').length, 1);
|
|
109
|
+
} finally {
|
|
110
|
+
globalThis.window = priorWindow;
|
|
111
|
+
}
|
|
112
|
+
});
|
package/src/data/app-glyphs.json
CHANGED
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
"inner": "<rect x=\"5\" y=\"11\" width=\"14\" height=\"10\" rx=\"2\" fill=\"currentColor\"/><path d=\"M8,11V7a4,4,0,0,1,8,0v4\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\"/>",
|
|
20
20
|
"viewBox": "0 0 24 24"
|
|
21
21
|
},
|
|
22
|
+
"hermiq": {
|
|
23
|
+
"inner": "<path fill=\"currentColor\" d=\"M19,1L17.74,3.75L15,5L17.74,6.26L19,9L20.25,6.26L23,5L20.25,3.75M9,4L6.5,9.5L1,12L6.5,14.5L9,20L11.5,14.5L17,12L11.5,9.5M19,15L17.74,17.74L15,19L17.74,20.25L19,23L20.25,20.25L23,19L20.25,17.74\"/>",
|
|
24
|
+
"viewBox": "0 0 24 24"
|
|
25
|
+
},
|
|
22
26
|
"financeq": {
|
|
23
27
|
"inner": "<path fill=\"currentColor\" d=\"M6,16.5L3,19.44V11H6M11,14.66L9.43,13.32L8,14.64V7H11M16,13L13,16V3H16M18.81,12.81L17,11H22V16L20.21,14.21L13,21.36L9.53,18.34L5.75,22H3L9.47,15.66L13,18.64\"/>",
|
|
24
28
|
"viewBox": "0 0 24 24"
|