@conduction/docusaurus-preset 3.28.0 → 3.32.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/MISSING_COMPONENTS.md +6 -2
- 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/BuildMock/BuildMock.jsx +101 -0
- package/src/components/BuildMock/BuildMock.module.css +290 -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/components/DetailHero/DetailHero.jsx +68 -16
- package/src/components/DetailHero/DetailHero.module.css +31 -1
- package/src/components/FlowMock/FlowMock.jsx +151 -0
- package/src/components/FlowMock/FlowMock.module.css +164 -0
- package/src/components/KanbanMock/KanbanMock.jsx +119 -0
- package/src/components/KanbanMock/KanbanMock.module.css +233 -0
- package/src/components/LeafMock/LeafMock.jsx +173 -0
- package/src/components/LeafMock/LeafMock.module.css +237 -0
- package/src/components/RotatingCards/RotatingCards.module.css +5 -2
- package/src/components/Showcase/Showcase.module.css +5 -2
- package/src/components/WidgetShelf/WidgetShelf.jsx +52 -13
- package/src/components/WidgetShelf/WidgetShelf.module.css +84 -5
- package/src/components/index.js +4 -0
- package/src/components/primitives/SectionHead.module.css +6 -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
|
+
});
|
|
@@ -38,6 +38,16 @@
|
|
|
38
38
|
* (or secondary) variant to the KNVB-orange accent. Reserved for
|
|
39
39
|
* product pages with an orange-leaning brand identity (launchpad).
|
|
40
40
|
*
|
|
41
|
+
* GitHub lives in the badge row, not the CTA row: the downloads
|
|
42
|
+
* counter links to the app's repository and a "View on GitHub" chip
|
|
43
|
+
* sits next to it (both new-tab). The URL comes from the `repoHref`
|
|
44
|
+
* prop when given; otherwise a GitHub-pointing `tertiaryCta` href is
|
|
45
|
+
* reused (and that tertiary is then dropped from the CTA row, so
|
|
46
|
+
* existing pages upgrade without edits); otherwise it falls back to
|
|
47
|
+
* https://github.com/ConductionNL/{appId}. The CTA row therefore
|
|
48
|
+
* normally holds just the primary + secondary pair; a non-GitHub
|
|
49
|
+
* tertiary still renders for compatibility.
|
|
50
|
+
*
|
|
41
51
|
* `background="cobalt"` paints the hero in a full-bleed cobalt panel
|
|
42
52
|
* with white type — the product-page identity used on the
|
|
43
53
|
* {slug}.conduction.nl landings. Default (undefined) keeps the
|
|
@@ -85,8 +95,26 @@ export default function DetailHero({
|
|
|
85
95
|
appId,
|
|
86
96
|
downloads,
|
|
87
97
|
background,
|
|
98
|
+
repoHref,
|
|
88
99
|
}) {
|
|
89
100
|
const dlCount = downloads != null ? downloads : (appId ? downloadsForApp(appId) : 0);
|
|
101
|
+
/* GitHub repo link for the badge row. Priority: explicit `repoHref`
|
|
102
|
+
prop → a GitHub-pointing tertiaryCta (the old "View on GitHub"
|
|
103
|
+
ghost button, which this hero now renders as a meta-row chip
|
|
104
|
+
instead of a third CTA) → the ConductionNL org default for the
|
|
105
|
+
appId. Both the downloads counter and the "View on GitHub" chip
|
|
106
|
+
link here, in a new tab. */
|
|
107
|
+
const tertiaryIsRepo = Boolean(
|
|
108
|
+
tertiaryCta && typeof tertiaryCta.href === 'string' && tertiaryCta.href.includes('github.com')
|
|
109
|
+
);
|
|
110
|
+
const resolvedRepoHref = repoHref
|
|
111
|
+
|| (tertiaryIsRepo ? tertiaryCta.href : undefined)
|
|
112
|
+
|| (appId ? `https://github.com/ConductionNL/${appId}` : undefined);
|
|
113
|
+
/* A GitHub tertiary CTA is "moved up top": it renders as the meta-row
|
|
114
|
+
chip and disappears from the CTA row, which then holds just the
|
|
115
|
+
primary + secondary pair. Any other tertiary (docs, demo, ...)
|
|
116
|
+
keeps rendering as before for compatibility. */
|
|
117
|
+
const renderedTertiaryCta = tertiaryIsRepo ? null : tertiaryCta;
|
|
90
118
|
const hasIllustration = Boolean(illustration);
|
|
91
119
|
/* Default the title mark to the canonical app glyph (the same logo
|
|
92
120
|
served on identity.conduction.nl/apps) when the caller doesn't pass
|
|
@@ -238,7 +266,7 @@ export default function DetailHero({
|
|
|
238
266
|
|
|
239
267
|
<div className={styles.headInner}>
|
|
240
268
|
<div className={styles.copy}>
|
|
241
|
-
{(resolvedStatus || resolvedVersion || locales || dlCount > 0) && (
|
|
269
|
+
{(resolvedStatus || resolvedVersion || locales || dlCount > 0 || resolvedRepoHref) && (
|
|
242
270
|
<div className={styles.badgeRow}>
|
|
243
271
|
{resolvedStatus && (
|
|
244
272
|
<span className={styles.badge}>
|
|
@@ -248,17 +276,39 @@ export default function DetailHero({
|
|
|
248
276
|
)}
|
|
249
277
|
{resolvedVersion && <span className={[styles.badge, styles.versionBadge].join(' ')}>{resolvedVersion}</span>}
|
|
250
278
|
{locales && <span className={[styles.badge, styles.versionBadge].join(' ')}>{locales}</span>}
|
|
251
|
-
{dlCount > 0 && (
|
|
252
|
-
|
|
279
|
+
{dlCount > 0 && (() => {
|
|
280
|
+
/* The downloads counter links to the repo when one
|
|
281
|
+
resolves; a plain chip otherwise. */
|
|
282
|
+
const DlTag = resolvedRepoHref ? 'a' : 'span';
|
|
283
|
+
const dlLinkProps = resolvedRepoHref
|
|
284
|
+
? {href: resolvedRepoHref, target: '_blank', rel: 'noopener noreferrer'}
|
|
285
|
+
: {};
|
|
286
|
+
return (
|
|
287
|
+
<DlTag
|
|
288
|
+
className={[styles.badge, styles.downloadsBadge].join(' ')}
|
|
289
|
+
title="Total release-asset downloads from GitHub. Updated weekdays at 09:00."
|
|
290
|
+
data-app-downloads={appId || ''}
|
|
291
|
+
{...dlLinkProps}
|
|
292
|
+
>
|
|
293
|
+
<svg className={styles.downloadIcon} viewBox="0 0 24 24" aria-hidden="true">
|
|
294
|
+
<path d="M12 3v12m0 0l-5-5m5 5l5-5M5 21h14"/>
|
|
295
|
+
</svg>
|
|
296
|
+
{formatDownloads(dlCount)} downloads
|
|
297
|
+
</DlTag>
|
|
298
|
+
);
|
|
299
|
+
})()}
|
|
300
|
+
{resolvedRepoHref && (
|
|
301
|
+
<a
|
|
253
302
|
className={[styles.badge, styles.downloadsBadge].join(' ')}
|
|
254
|
-
|
|
255
|
-
|
|
303
|
+
href={resolvedRepoHref}
|
|
304
|
+
target="_blank"
|
|
305
|
+
rel="noopener noreferrer"
|
|
256
306
|
>
|
|
257
|
-
<svg className={styles.
|
|
258
|
-
<path d="
|
|
307
|
+
<svg className={styles.repoIcon} viewBox="0 0 16 16" aria-hidden="true">
|
|
308
|
+
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/>
|
|
259
309
|
</svg>
|
|
260
|
-
|
|
261
|
-
</
|
|
310
|
+
View on GitHub
|
|
311
|
+
</a>
|
|
262
312
|
)}
|
|
263
313
|
</div>
|
|
264
314
|
)}
|
|
@@ -280,7 +330,7 @@ export default function DetailHero({
|
|
|
280
330
|
{tagline && <p className={styles.tagline}>{tagline}</p>}
|
|
281
331
|
{intro && <div className={styles.intro}>{intro}</div>}
|
|
282
332
|
|
|
283
|
-
{(primaryCta || secondaryCta ||
|
|
333
|
+
{(primaryCta || secondaryCta || renderedTertiaryCta) && (
|
|
284
334
|
<div className={styles.actions}>
|
|
285
335
|
{primaryCta && (
|
|
286
336
|
<Button
|
|
@@ -306,19 +356,21 @@ export default function DetailHero({
|
|
|
306
356
|
{secondaryCta.label}
|
|
307
357
|
</Button>
|
|
308
358
|
)}
|
|
309
|
-
{
|
|
359
|
+
{renderedTertiaryCta && (
|
|
310
360
|
/* On a cobalt-bg hero the default ghost variant
|
|
311
361
|
(cobalt-700 text) disappears against the dark panel;
|
|
312
362
|
auto-switch to on-dark-tertiary (white text + white
|
|
313
363
|
border) so the CTA reads at parity with the primary
|
|
314
364
|
and secondary buttons. Sites can still pass an
|
|
315
|
-
explicit `variant` to opt out.
|
|
365
|
+
explicit `variant` to opt out. GitHub-pointing
|
|
366
|
+
tertiaries never reach this row: they render as the
|
|
367
|
+
"View on GitHub" chip in the badge row instead. */
|
|
316
368
|
<Button
|
|
317
|
-
variant={
|
|
318
|
-
href={
|
|
319
|
-
icon={
|
|
369
|
+
variant={renderedTertiaryCta.variant || (background === 'cobalt' ? 'on-dark-tertiary' : 'ghost')}
|
|
370
|
+
href={renderedTertiaryCta.href}
|
|
371
|
+
icon={renderedTertiaryCta.icon}
|
|
320
372
|
>
|
|
321
|
-
{
|
|
373
|
+
{renderedTertiaryCta.label} →
|
|
322
374
|
</Button>
|
|
323
375
|
)}
|
|
324
376
|
</div>
|
|
@@ -37,7 +37,22 @@
|
|
|
37
37
|
.bgCobalt .sep { color: var(--c-cobalt-300); }
|
|
38
38
|
.bgCobalt .badge { color: var(--c-cobalt-100); }
|
|
39
39
|
.bgCobalt .versionBadge { color: var(--c-cobalt-200); }
|
|
40
|
-
|
|
40
|
+
/* On the cobalt hero the default chip (cobalt text on a cobalt-50
|
|
41
|
+
plate) inverts to full-opacity white text on a translucent white
|
|
42
|
+
chip. The 16%-white plate blends to #45649E over cobalt, which
|
|
43
|
+
keeps white text at 5.9:1 — above the 4.5:1 WCAG AA floor. The
|
|
44
|
+
earlier rule only recoloured the text and left the light plate,
|
|
45
|
+
which made the chip near-invisible on the blue panel. */
|
|
46
|
+
.bgCobalt .downloadsBadge {
|
|
47
|
+
color: white;
|
|
48
|
+
background: rgba(255, 255, 255, 0.16);
|
|
49
|
+
border-color: rgba(255, 255, 255, 0.35);
|
|
50
|
+
}
|
|
51
|
+
.bgCobalt a.downloadsBadge:hover {
|
|
52
|
+
color: white;
|
|
53
|
+
background: rgba(255, 255, 255, 0.26);
|
|
54
|
+
border-color: rgba(255, 255, 255, 0.5);
|
|
55
|
+
}
|
|
41
56
|
.bgCobalt .title,
|
|
42
57
|
.bgCobalt .titleText { color: white; }
|
|
43
58
|
.bgCobalt .tagline { color: var(--c-cobalt-100); }
|
|
@@ -91,6 +106,9 @@
|
|
|
91
106
|
}
|
|
92
107
|
.versionBadge { color: var(--c-cobalt-400); }
|
|
93
108
|
|
|
109
|
+
/* Meta-row chip. Shared by the downloads counter and the "View on
|
|
110
|
+
GitHub" link; both are usually <a> elements pointing at the app's
|
|
111
|
+
repository, so the chip carries link resets + a hover state. */
|
|
94
112
|
.downloadsBadge {
|
|
95
113
|
color: var(--c-blue-cobalt);
|
|
96
114
|
background: var(--c-cobalt-50);
|
|
@@ -98,6 +116,13 @@
|
|
|
98
116
|
border-radius: var(--radius-sm);
|
|
99
117
|
padding: 4px 10px;
|
|
100
118
|
letter-spacing: 0.04em;
|
|
119
|
+
text-decoration: none;
|
|
120
|
+
transition: background 120ms ease, border-color 120ms ease;
|
|
121
|
+
}
|
|
122
|
+
a.downloadsBadge:hover {
|
|
123
|
+
color: var(--c-blue-cobalt);
|
|
124
|
+
background: var(--c-cobalt-100);
|
|
125
|
+
border-color: var(--c-cobalt-200);
|
|
101
126
|
}
|
|
102
127
|
.downloadIcon {
|
|
103
128
|
width: 12px;
|
|
@@ -108,6 +133,11 @@
|
|
|
108
133
|
stroke-linecap: round;
|
|
109
134
|
stroke-linejoin: round;
|
|
110
135
|
}
|
|
136
|
+
.repoIcon {
|
|
137
|
+
width: 12px;
|
|
138
|
+
height: 12px;
|
|
139
|
+
fill: currentColor;
|
|
140
|
+
}
|
|
111
141
|
|
|
112
142
|
.title {
|
|
113
143
|
font-size: 64px;
|