@conduction/docusaurus-preset 3.41.1 → 3.43.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 +1 -1
- package/src/components/DetailHero/DetailHero.jsx +6 -0
- package/src/components/HiddenGame/HiddenGame.jsx +190 -0
- package/src/components/HiddenGame/HiddenGame.module.css +42 -0
- package/src/components/HiddenGame/__tests__/matchers.test.js +117 -0
- package/src/components/HiddenGame/matchers.js +168 -0
- package/src/components/MonsterRun/MonsterRun.jsx +233 -0
- package/src/components/MonsterRun/MonsterRun.module.css +231 -0
- package/src/components/MonsterRun/__tests__/engine.test.js +218 -0
- package/src/components/MonsterRun/engine.js +194 -0
- package/src/components/index.js +2 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <MonsterRun />
|
|
3
|
+
*
|
|
4
|
+
* La Frankendesk's game, and the only one that belongs to a blog post
|
|
5
|
+
* rather than an app. The monster the post stitches together goes for
|
|
6
|
+
* a run: jump the forks, duck the patch sets, collect the parts it was
|
|
7
|
+
* made of.
|
|
8
|
+
*
|
|
9
|
+
* The obstacles are the post's own argument. A fork is a permanent
|
|
10
|
+
* maintenance bill, a patch set is a thing you carry forever, a
|
|
11
|
+
* release train arrives whether you are ready or not, and a design
|
|
12
|
+
* language is the thing you keep walking into. What is worth picking
|
|
13
|
+
* up is what the post says is already shared: a token, a protocol, a
|
|
14
|
+
* component.
|
|
15
|
+
*
|
|
16
|
+
* Two inputs, jump and duck, so it is a runner rather than a fourth
|
|
17
|
+
* game about picking the right answer. The rules live in ./engine.js
|
|
18
|
+
* with no DOM and no clock.
|
|
19
|
+
*
|
|
20
|
+
* Usage:
|
|
21
|
+
*
|
|
22
|
+
* <MonsterRun />
|
|
23
|
+
*
|
|
24
|
+
* Fires the shared `connext:gameend` event on game over, and listens
|
|
25
|
+
* for `connext:gamereplay`, like every other mini-game.
|
|
26
|
+
*
|
|
27
|
+
* Accessibility: jump and duck are real buttons as well as arrow keys,
|
|
28
|
+
* and the track ahead is announced as a sentence, so the run can be
|
|
29
|
+
* played by someone who cannot see it coming.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import React, {useCallback, useEffect, useRef, useState} from 'react';
|
|
33
|
+
import {translate} from '@docusaurus/Translate';
|
|
34
|
+
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
|
35
|
+
import {createGame, step, jump, duck, summarise, LOW} from './engine';
|
|
36
|
+
import styles from './MonsterRun.module.css';
|
|
37
|
+
|
|
38
|
+
const GAME_ID = 'monster-run';
|
|
39
|
+
const TICK_MS = 40;
|
|
40
|
+
|
|
41
|
+
function obstacleCopy(what) {
|
|
42
|
+
switch (what) {
|
|
43
|
+
case 'fork':
|
|
44
|
+
return translate({id: 'preset.monsterRun.obstacle.fork', message: 'a fork', description: 'Monster-run obstacle on the ground: a fork of the code'});
|
|
45
|
+
case 'patchset':
|
|
46
|
+
return translate({id: 'preset.monsterRun.obstacle.patchset', message: 'a patch set', description: 'Monster-run obstacle overhead: a patch set'});
|
|
47
|
+
case 'releaseTrain':
|
|
48
|
+
return translate({id: 'preset.monsterRun.obstacle.releaseTrain', message: 'a release train', description: 'Monster-run obstacle on the ground: a release train'});
|
|
49
|
+
default:
|
|
50
|
+
return translate({id: 'preset.monsterRun.obstacle.designLanguage', message: 'another design language', description: 'Monster-run obstacle overhead: a design language'});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function partCopy(what) {
|
|
55
|
+
switch (what) {
|
|
56
|
+
case 'token':
|
|
57
|
+
return translate({id: 'preset.monsterRun.part.token', message: 'a token', description: 'Monster-run pick-up: a design token'});
|
|
58
|
+
case 'protocol':
|
|
59
|
+
return translate({id: 'preset.monsterRun.part.protocol', message: 'a protocol', description: 'Monster-run pick-up: a protocol'});
|
|
60
|
+
default:
|
|
61
|
+
return translate({id: 'preset.monsterRun.part.component', message: 'a component', description: 'Monster-run pick-up: a component'});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export default function MonsterRun({className}) {
|
|
66
|
+
const {i18n} = useDocusaurusContext();
|
|
67
|
+
const locale = (i18n && i18n.currentLocale) || 'en';
|
|
68
|
+
|
|
69
|
+
const [game, setGame] = useState(null);
|
|
70
|
+
const gameRef = useRef(null);
|
|
71
|
+
const startedAtRef = useRef(0);
|
|
72
|
+
const endedRef = useRef(false);
|
|
73
|
+
|
|
74
|
+
const running = Boolean(game) && !game.over;
|
|
75
|
+
const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now()) - startedAtRef.current;
|
|
76
|
+
|
|
77
|
+
const begin = useCallback(() => {
|
|
78
|
+
endedRef.current = false;
|
|
79
|
+
startedAtRef.current = (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
|
80
|
+
const fresh = createGame({seed: Math.floor(Math.random() * 2 ** 31), now: 0});
|
|
81
|
+
gameRef.current = fresh;
|
|
82
|
+
setGame(fresh);
|
|
83
|
+
}, []);
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (!running) return undefined;
|
|
87
|
+
const id = setInterval(() => {
|
|
88
|
+
const next = step(gameRef.current, now());
|
|
89
|
+
if (next !== gameRef.current) {
|
|
90
|
+
gameRef.current = next;
|
|
91
|
+
setGame(next);
|
|
92
|
+
}
|
|
93
|
+
}, TICK_MS);
|
|
94
|
+
return () => clearInterval(id);
|
|
95
|
+
}, [running]);
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (!game || !game.over || endedRef.current) return;
|
|
99
|
+
endedRef.current = true;
|
|
100
|
+
if (typeof window === 'undefined') return;
|
|
101
|
+
window.dispatchEvent(new CustomEvent('connext:gameend', {
|
|
102
|
+
detail: {
|
|
103
|
+
id: GAME_ID,
|
|
104
|
+
won: false,
|
|
105
|
+
score: game.score,
|
|
106
|
+
summary: summarise(game, locale),
|
|
107
|
+
title: translate({id: 'preset.monsterRun.over.title', message: 'The monster is down.', description: 'Headline on the game-over dialog after a monster-run run'}),
|
|
108
|
+
subtitle: translate({id: 'preset.monsterRun.over.subtitle', message: 'Three forks will do that. The parts were never the problem.', description: 'Subtitle on the game-over dialog after a monster-run run'}),
|
|
109
|
+
},
|
|
110
|
+
}));
|
|
111
|
+
}, [game, locale]);
|
|
112
|
+
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (typeof window === 'undefined') return undefined;
|
|
115
|
+
const onReplay = (e) => { if (e.detail && e.detail.id === GAME_ID) begin(); };
|
|
116
|
+
window.addEventListener('connext:gamereplay', onReplay);
|
|
117
|
+
return () => window.removeEventListener('connext:gamereplay', onReplay);
|
|
118
|
+
}, [begin]);
|
|
119
|
+
|
|
120
|
+
const act = useCallback((what) => {
|
|
121
|
+
if (!gameRef.current || gameRef.current.over) return;
|
|
122
|
+
const next = what === 'jump' ? jump(gameRef.current) : duck(gameRef.current);
|
|
123
|
+
gameRef.current = next;
|
|
124
|
+
setGame(next);
|
|
125
|
+
}, []);
|
|
126
|
+
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
if (!running || typeof window === 'undefined') return undefined;
|
|
129
|
+
const onKey = (e) => {
|
|
130
|
+
if (e.key === 'ArrowUp' || e.key === ' ' || e.key === 'w') { e.preventDefault(); act('jump'); }
|
|
131
|
+
if (e.key === 'ArrowDown' || e.key === 's') { e.preventDefault(); act('duck'); }
|
|
132
|
+
};
|
|
133
|
+
window.addEventListener('keydown', onKey);
|
|
134
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
135
|
+
}, [running, act]);
|
|
136
|
+
|
|
137
|
+
const track = game ? game.track : [];
|
|
138
|
+
const posture = game ? game.posture : 'run';
|
|
139
|
+
const last = game ? game.last : null;
|
|
140
|
+
|
|
141
|
+
/* What is coming, in words, for anyone who cannot watch it come. */
|
|
142
|
+
const incoming = track.slice(0, 4).find((c) => c && c.kind === 'obstacle');
|
|
143
|
+
const ahead = incoming
|
|
144
|
+
? translate(
|
|
145
|
+
{id: 'preset.monsterRun.ahead', message: 'Coming up: {what}, {where}', description: 'Spoken description of the next obstacle. {what} is the obstacle, {where} says whether to jump or duck.'},
|
|
146
|
+
{
|
|
147
|
+
what: obstacleCopy(incoming.what),
|
|
148
|
+
where: incoming.lane === LOW
|
|
149
|
+
? translate({id: 'preset.monsterRun.ahead.low', message: 'on the ground', description: 'Where a monster-run obstacle sits when it must be jumped'})
|
|
150
|
+
: translate({id: 'preset.monsterRun.ahead.high', message: 'overhead', description: 'Where a monster-run obstacle sits when it must be ducked'}),
|
|
151
|
+
},
|
|
152
|
+
)
|
|
153
|
+
: translate({id: 'preset.monsterRun.ahead.clear', message: 'The way ahead is clear.', description: 'Spoken description when no monster-run obstacle is near'});
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<section className={[styles.mr, className].filter(Boolean).join(' ')} aria-labelledby="monster-run-title">
|
|
157
|
+
<header className={styles.head}>
|
|
158
|
+
<div>
|
|
159
|
+
<p className={styles.eyebrow}>
|
|
160
|
+
{translate({id: 'preset.monsterRun.eyebrow', message: 'Mini-game', description: 'Eyebrow above the monster-run game'})}
|
|
161
|
+
</p>
|
|
162
|
+
<h3 className={styles.title} id="monster-run-title">
|
|
163
|
+
{translate({id: 'preset.monsterRun.title', message: 'The monster goes for a run', description: 'Name of the La Frankendesk mini-game'})}
|
|
164
|
+
</h3>
|
|
165
|
+
<p className={styles.lede}>
|
|
166
|
+
{translate({id: 'preset.monsterRun.lede', message: 'Jump the forks, duck the patch sets, and pick up the parts it was stitched together from. The parts were never the problem.', description: 'One-line explanation of the monster-run rules'})}
|
|
167
|
+
</p>
|
|
168
|
+
</div>
|
|
169
|
+
<div className={styles.hud} role="status" aria-live="polite">
|
|
170
|
+
<span className={styles.hudPill}>
|
|
171
|
+
{translate({id: 'preset.monsterRun.hud.score', message: 'Score {score}', description: 'Score readout on the monster-run HUD'}, {score: Number(game ? game.score : 0).toLocaleString(locale)})}
|
|
172
|
+
</span>
|
|
173
|
+
<span className={styles.hudPill}>
|
|
174
|
+
{translate({id: 'preset.monsterRun.hud.lives', message: 'Stitches {lives}', description: 'Remaining-lives readout on the monster-run HUD'}, {lives: game ? game.lives : 3})}
|
|
175
|
+
</span>
|
|
176
|
+
</div>
|
|
177
|
+
</header>
|
|
178
|
+
|
|
179
|
+
<div className={styles.stage} aria-hidden="true">
|
|
180
|
+
<div className={[styles.monster, styles[`posture-${posture}`]].filter(Boolean).join(' ')}>
|
|
181
|
+
<span className={styles.head1} />
|
|
182
|
+
<span className={styles.body1} />
|
|
183
|
+
<span className={styles.bolt} />
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
<div className={styles.track}>
|
|
187
|
+
{track.map((cell, i) => (
|
|
188
|
+
<span
|
|
189
|
+
key={i}
|
|
190
|
+
className={[
|
|
191
|
+
styles.cell,
|
|
192
|
+
cell && cell.kind === 'obstacle' && styles.obstacle,
|
|
193
|
+
cell && cell.kind === 'part' && styles.part,
|
|
194
|
+
cell && cell.lane === LOW ? styles.low : cell && styles.high,
|
|
195
|
+
].filter(Boolean).join(' ')}>
|
|
196
|
+
{cell && (cell.kind === 'obstacle' ? obstacleCopy(cell.what) : partCopy(cell.what))}
|
|
197
|
+
</span>
|
|
198
|
+
))}
|
|
199
|
+
</div>
|
|
200
|
+
<span className={styles.ground} />
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
<p className={styles.ahead} role="status" aria-live="polite">
|
|
204
|
+
{game ? ahead : translate({id: 'preset.monsterRun.idle', message: 'It has been lying on the table since the last section.', description: 'Placeholder before the monster-run game starts'})}
|
|
205
|
+
</p>
|
|
206
|
+
|
|
207
|
+
<div className={styles.controls}>
|
|
208
|
+
<button type="button" className={styles.action} onClick={() => act('jump')} disabled={!running}>
|
|
209
|
+
{translate({id: 'preset.monsterRun.jump', message: 'Jump', description: 'Button that makes the monster jump'})}
|
|
210
|
+
</button>
|
|
211
|
+
<button type="button" className={styles.action} onClick={() => act('duck')} disabled={!running}>
|
|
212
|
+
{translate({id: 'preset.monsterRun.duck', message: 'Duck', description: 'Button that makes the monster duck'})}
|
|
213
|
+
</button>
|
|
214
|
+
<button type="button" className={styles.start} onClick={begin}>
|
|
215
|
+
{game
|
|
216
|
+
? translate({id: 'preset.monsterRun.restart', message: 'Again', description: 'Button that restarts the monster-run game'})
|
|
217
|
+
: translate({id: 'preset.monsterRun.start', message: 'It lives', description: 'Button that starts the monster-run game'})}
|
|
218
|
+
</button>
|
|
219
|
+
<p className={styles.hint}>
|
|
220
|
+
{last && last.result === 'hit' && translate(
|
|
221
|
+
{id: 'preset.monsterRun.feedback.hit', message: 'Straight into {what}.', description: 'Feedback after hitting an obstacle. {what} is the obstacle.'},
|
|
222
|
+
{what: obstacleCopy(last.what)},
|
|
223
|
+
)}
|
|
224
|
+
{last && last.result === 'part' && translate(
|
|
225
|
+
{id: 'preset.monsterRun.feedback.part', message: 'Picked up {what}.', description: 'Feedback after collecting a part. {what} is the part.'},
|
|
226
|
+
{what: partCopy(last.what)},
|
|
227
|
+
)}
|
|
228
|
+
{(!last || last.result === 'clear') && translate({id: 'preset.monsterRun.hint', message: 'Up jumps, down ducks. Arrow keys work too.', description: 'Hint under the monster-run controls'})}
|
|
229
|
+
</p>
|
|
230
|
+
</div>
|
|
231
|
+
</section>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <MonsterRun /> styles.
|
|
3
|
+
*
|
|
4
|
+
* A side-on strip: the monster on the left, the world walking towards
|
|
5
|
+
* it. Tokens only, one accent for what would stop it, and the track
|
|
6
|
+
* also spells out what is coming, so the run is readable without the
|
|
7
|
+
* picture.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
.mr {
|
|
11
|
+
border: 1px solid var(--c-cobalt-100);
|
|
12
|
+
border-radius: var(--radius-lg);
|
|
13
|
+
background: white;
|
|
14
|
+
padding: clamp(16px, 3vw, 28px);
|
|
15
|
+
font-family: var(--conduction-typography-font-family-body);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.head {
|
|
19
|
+
display: flex;
|
|
20
|
+
flex-wrap: wrap;
|
|
21
|
+
gap: 16px;
|
|
22
|
+
align-items: flex-start;
|
|
23
|
+
justify-content: space-between;
|
|
24
|
+
margin-bottom: 16px;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.eyebrow {
|
|
28
|
+
margin: 0 0 4px;
|
|
29
|
+
font-family: var(--conduction-typography-font-family-code);
|
|
30
|
+
font-size: 11px;
|
|
31
|
+
letter-spacing: 0.12em;
|
|
32
|
+
text-transform: uppercase;
|
|
33
|
+
color: var(--c-orange-knvb);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.title {
|
|
37
|
+
margin: 0 0 6px;
|
|
38
|
+
font-size: 20px;
|
|
39
|
+
font-weight: 700;
|
|
40
|
+
color: var(--c-cobalt-900);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.lede {
|
|
44
|
+
margin: 0;
|
|
45
|
+
max-width: 58ch;
|
|
46
|
+
font-size: 14px;
|
|
47
|
+
line-height: 1.5;
|
|
48
|
+
color: var(--c-cobalt-700);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.hud {
|
|
52
|
+
display: flex;
|
|
53
|
+
gap: 8px;
|
|
54
|
+
flex-wrap: wrap;
|
|
55
|
+
font-family: var(--conduction-typography-font-family-code);
|
|
56
|
+
font-size: 12px;
|
|
57
|
+
font-variant-numeric: tabular-nums;
|
|
58
|
+
}
|
|
59
|
+
.hudPill {
|
|
60
|
+
padding: 6px 10px;
|
|
61
|
+
border-radius: var(--radius-pill);
|
|
62
|
+
background: var(--c-cobalt-50);
|
|
63
|
+
color: var(--c-cobalt-900);
|
|
64
|
+
white-space: nowrap;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* ======================================================================
|
|
68
|
+
The strip
|
|
69
|
+
====================================================================== */
|
|
70
|
+
|
|
71
|
+
.stage {
|
|
72
|
+
position: relative;
|
|
73
|
+
display: flex;
|
|
74
|
+
align-items: flex-end;
|
|
75
|
+
gap: 10px;
|
|
76
|
+
height: 132px;
|
|
77
|
+
padding: 0 12px 14px;
|
|
78
|
+
border-radius: var(--radius-md);
|
|
79
|
+
background: var(--c-cobalt-50);
|
|
80
|
+
overflow: hidden;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.ground {
|
|
84
|
+
position: absolute;
|
|
85
|
+
left: 0;
|
|
86
|
+
right: 0;
|
|
87
|
+
bottom: 12px;
|
|
88
|
+
height: 2px;
|
|
89
|
+
background: var(--c-cobalt-200);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* The monster: a head, a body and one bolt. Three boxes is enough
|
|
93
|
+
silhouette at this size, and it keeps the whole thing token-built. */
|
|
94
|
+
.monster {
|
|
95
|
+
position: relative;
|
|
96
|
+
width: 26px;
|
|
97
|
+
height: 46px;
|
|
98
|
+
flex-shrink: 0;
|
|
99
|
+
z-index: 1;
|
|
100
|
+
transition: transform 120ms ease-out, height 120ms ease-out;
|
|
101
|
+
}
|
|
102
|
+
.head1 {
|
|
103
|
+
position: absolute;
|
|
104
|
+
top: 0;
|
|
105
|
+
left: 3px;
|
|
106
|
+
width: 20px;
|
|
107
|
+
height: 16px;
|
|
108
|
+
border-radius: 3px 3px 2px 2px;
|
|
109
|
+
background: var(--c-forest-500, var(--c-mint-500));
|
|
110
|
+
}
|
|
111
|
+
.body1 {
|
|
112
|
+
position: absolute;
|
|
113
|
+
top: 16px;
|
|
114
|
+
left: 0;
|
|
115
|
+
right: 0;
|
|
116
|
+
bottom: 0;
|
|
117
|
+
border-radius: 3px;
|
|
118
|
+
background: var(--c-cobalt-900);
|
|
119
|
+
}
|
|
120
|
+
.bolt {
|
|
121
|
+
position: absolute;
|
|
122
|
+
top: 6px;
|
|
123
|
+
right: 0;
|
|
124
|
+
width: 6px;
|
|
125
|
+
height: 3px;
|
|
126
|
+
border-radius: 1px;
|
|
127
|
+
background: var(--c-orange-knvb);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.posture-jump { transform: translateY(-34px); }
|
|
131
|
+
.posture-duck { height: 26px; }
|
|
132
|
+
.posture-duck .head1 { top: 0; height: 12px; }
|
|
133
|
+
.posture-duck .body1 { top: 12px; }
|
|
134
|
+
|
|
135
|
+
.track {
|
|
136
|
+
display: flex;
|
|
137
|
+
align-items: flex-end;
|
|
138
|
+
gap: 6px;
|
|
139
|
+
flex: 1;
|
|
140
|
+
height: 100%;
|
|
141
|
+
padding-bottom: 2px;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.cell {
|
|
145
|
+
flex: 1;
|
|
146
|
+
min-width: 0;
|
|
147
|
+
display: flex;
|
|
148
|
+
align-items: center;
|
|
149
|
+
justify-content: center;
|
|
150
|
+
padding: 2px;
|
|
151
|
+
border-radius: var(--radius-sm);
|
|
152
|
+
font-size: 8px;
|
|
153
|
+
line-height: 1.1;
|
|
154
|
+
text-align: center;
|
|
155
|
+
color: transparent;
|
|
156
|
+
overflow: hidden;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/* Only the cells holding something get a box; the rest are air. */
|
|
160
|
+
.obstacle {
|
|
161
|
+
background: var(--c-orange-knvb);
|
|
162
|
+
color: white;
|
|
163
|
+
font-weight: 600;
|
|
164
|
+
}
|
|
165
|
+
.part {
|
|
166
|
+
background: var(--c-mint-300);
|
|
167
|
+
color: var(--c-cobalt-900);
|
|
168
|
+
font-weight: 600;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.low { height: 34px; align-self: flex-end; }
|
|
172
|
+
.high { height: 30px; align-self: flex-start; margin-top: 8px; }
|
|
173
|
+
|
|
174
|
+
.ahead {
|
|
175
|
+
margin: 10px 0 0;
|
|
176
|
+
font-size: 13px;
|
|
177
|
+
color: var(--c-cobalt-700);
|
|
178
|
+
min-height: 1.4em;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.controls {
|
|
182
|
+
display: flex;
|
|
183
|
+
flex-wrap: wrap;
|
|
184
|
+
align-items: center;
|
|
185
|
+
gap: 10px;
|
|
186
|
+
margin-top: 12px;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.action {
|
|
190
|
+
background: white;
|
|
191
|
+
color: var(--c-cobalt-900);
|
|
192
|
+
border: 1px solid var(--c-cobalt-200);
|
|
193
|
+
padding: 10px 20px;
|
|
194
|
+
border-radius: var(--radius-md);
|
|
195
|
+
font-family: inherit;
|
|
196
|
+
font-weight: 600;
|
|
197
|
+
font-size: 14px;
|
|
198
|
+
cursor: pointer;
|
|
199
|
+
transition: border-color 120ms ease;
|
|
200
|
+
}
|
|
201
|
+
.action:hover:not(:disabled) { border-color: var(--c-blue-cobalt); color: var(--c-blue-cobalt); }
|
|
202
|
+
.action:disabled { opacity: 0.5; cursor: default; }
|
|
203
|
+
.action:focus-visible { outline: 2px solid var(--c-blue-cobalt); outline-offset: 2px; }
|
|
204
|
+
|
|
205
|
+
.start {
|
|
206
|
+
background: var(--c-blue-cobalt);
|
|
207
|
+
color: white;
|
|
208
|
+
border: 1px solid var(--c-blue-cobalt);
|
|
209
|
+
padding: 10px 18px;
|
|
210
|
+
border-radius: var(--radius-md);
|
|
211
|
+
font-family: inherit;
|
|
212
|
+
font-weight: 500;
|
|
213
|
+
font-size: 14px;
|
|
214
|
+
cursor: pointer;
|
|
215
|
+
transition: background 120ms ease;
|
|
216
|
+
}
|
|
217
|
+
.start:hover { background: var(--c-cobalt-700); border-color: var(--c-cobalt-700); }
|
|
218
|
+
.start:focus-visible { outline: 2px solid var(--c-cobalt-900); outline-offset: 2px; }
|
|
219
|
+
|
|
220
|
+
.hint {
|
|
221
|
+
margin: 0;
|
|
222
|
+
flex-basis: 100%;
|
|
223
|
+
font-size: 12px;
|
|
224
|
+
color: var(--c-cobalt-400);
|
|
225
|
+
min-height: 1.4em;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
@media (prefers-reduced-motion: reduce) {
|
|
229
|
+
/* The posture still changes, it just stops sliding there. */
|
|
230
|
+
.monster { transition: none; }
|
|
231
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine.test.js — the monster-run rules.
|
|
3
|
+
*
|
|
4
|
+
* The property the whole game rests on: obstacles never arrive so close
|
|
5
|
+
* together that no posture gets past both. The monster cannot be in the
|
|
6
|
+
* air and crouched at once, so a pair like that is a death the player
|
|
7
|
+
* could not have avoided, and an arcade game that kills you for nothing
|
|
8
|
+
* is one nobody plays twice.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const test = require('node:test');
|
|
14
|
+
const assert = require('node:assert/strict');
|
|
15
|
+
|
|
16
|
+
const {
|
|
17
|
+
createGame, step, jump, duck, clears, stepMs, summarise,
|
|
18
|
+
TRACK, LOW, HIGH, OBSTACLES, PARTS, DEFAULTS,
|
|
19
|
+
} = require('../engine.js');
|
|
20
|
+
|
|
21
|
+
/** Play the run properly: jump what is low, duck what is high. */
|
|
22
|
+
function playWell(state, steps) {
|
|
23
|
+
let s = state;
|
|
24
|
+
let t = s.nextStepAt;
|
|
25
|
+
for (let i = 0; i < steps && !s.over; i++) {
|
|
26
|
+
const incoming = s.track[0];
|
|
27
|
+
if (incoming && incoming.kind === 'obstacle') {
|
|
28
|
+
s = incoming.lane === LOW ? jump(s) : duck(s);
|
|
29
|
+
}
|
|
30
|
+
s = step(s, t);
|
|
31
|
+
t = s.nextStepAt;
|
|
32
|
+
}
|
|
33
|
+
return s;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test('a run starts on a full track, upright and alive', () => {
|
|
37
|
+
const s = createGame({seed: 1, now: 0});
|
|
38
|
+
assert.equal(s.track.length, TRACK);
|
|
39
|
+
assert.equal(s.posture, 'run');
|
|
40
|
+
assert.equal(s.lives, DEFAULTS.lives);
|
|
41
|
+
assert.equal(s.over, false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('the run starts on empty road, so nothing is hit before a key is touched', () => {
|
|
45
|
+
/* Found by playing it: the initial fill could drop an obstacle at
|
|
46
|
+
the monster's feet, and the run cost a life in its first second. */
|
|
47
|
+
for (let seed = 1; seed <= 30; seed++) {
|
|
48
|
+
const s = createGame({seed, now: 0});
|
|
49
|
+
const runUp = s.track.slice(0, DEFAULTS.leadIn);
|
|
50
|
+
assert.ok(
|
|
51
|
+
runUp.every((cell) => cell === null),
|
|
52
|
+
`seed ${seed}: the run starts with something already in the way`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('a run that is left alone survives its own lead-in', () => {
|
|
58
|
+
let s = createGame({seed: 9, now: 0});
|
|
59
|
+
for (let i = 0; i < DEFAULTS.leadIn; i++) s = step(s, s.nextStepAt);
|
|
60
|
+
assert.equal(s.lives, DEFAULTS.lives, 'a life went before the player could react');
|
|
61
|
+
assert.equal(s.over, false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('obstacles never arrive without room to answer the one before', () => {
|
|
65
|
+
/* Forty seeds, a few hundred steps each. A bad pair shows up as one
|
|
66
|
+
run in dozens, not as every run. */
|
|
67
|
+
for (let seed = 1; seed <= 40; seed++) {
|
|
68
|
+
let s = createGame({seed, now: 0});
|
|
69
|
+
let sinceObstacle = DEFAULTS.minGap;
|
|
70
|
+
let t = s.nextStepAt;
|
|
71
|
+
for (let i = 0; i < 300 && !s.over; i++) {
|
|
72
|
+
const incoming = s.track[0];
|
|
73
|
+
if (incoming && incoming.kind === 'obstacle') {
|
|
74
|
+
assert.ok(
|
|
75
|
+
sinceObstacle >= DEFAULTS.minGap,
|
|
76
|
+
`seed ${seed}: two obstacles ${sinceObstacle} steps apart, which no posture clears`,
|
|
77
|
+
);
|
|
78
|
+
sinceObstacle = 0;
|
|
79
|
+
s = incoming.lane === LOW ? jump(s) : duck(s);
|
|
80
|
+
} else {
|
|
81
|
+
sinceObstacle += 1;
|
|
82
|
+
}
|
|
83
|
+
s = step(s, t);
|
|
84
|
+
t = s.nextStepAt;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('jumping clears what is on the ground, ducking clears what is overhead', () => {
|
|
90
|
+
assert.equal(clears('jump', {kind: 'obstacle', lane: LOW}), true);
|
|
91
|
+
assert.equal(clears('run', {kind: 'obstacle', lane: LOW}), false);
|
|
92
|
+
assert.equal(clears('duck', {kind: 'obstacle', lane: LOW}), false, 'crouching under a fork');
|
|
93
|
+
|
|
94
|
+
assert.equal(clears('duck', {kind: 'obstacle', lane: HIGH}), true);
|
|
95
|
+
assert.equal(clears('jump', {kind: 'obstacle', lane: HIGH}), false, 'jumping into the thing overhead');
|
|
96
|
+
assert.equal(clears('run', {kind: 'obstacle', lane: HIGH}), false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('an empty track and a part are cleared by any posture', () => {
|
|
100
|
+
for (const posture of ['run', 'jump', 'duck']) {
|
|
101
|
+
assert.equal(clears(posture, null), true);
|
|
102
|
+
assert.equal(clears(posture, {kind: 'part', what: 'token'}), true);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('running into what you did not clear costs a life and names it', () => {
|
|
107
|
+
let s = createGame({seed: 3, now: 0});
|
|
108
|
+
s = {...s, track: [{kind: 'obstacle', what: 'fork', lane: LOW}, ...s.track.slice(1)]};
|
|
109
|
+
s = step(s, s.nextStepAt);
|
|
110
|
+
assert.equal(s.lives, DEFAULTS.lives - 1);
|
|
111
|
+
assert.equal(s.hits, 1);
|
|
112
|
+
assert.equal(s.last.result, 'hit');
|
|
113
|
+
assert.equal(s.last.what, 'fork');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('a part is picked up whatever the monster is doing', () => {
|
|
117
|
+
let s = createGame({seed: 4, now: 0});
|
|
118
|
+
s = {...s, track: [{kind: 'part', what: 'token', lane: HIGH}, ...s.track.slice(1)]};
|
|
119
|
+
s = step(jump(s), s.nextStepAt);
|
|
120
|
+
assert.equal(s.collected, 1);
|
|
121
|
+
assert.equal(s.score, DEFAULTS.pointsPerPart);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('a posture wears off, and cannot be changed mid-air', () => {
|
|
125
|
+
let s = createGame({seed: 5, now: 0});
|
|
126
|
+
s = jump(s);
|
|
127
|
+
assert.equal(s.posture, 'jump');
|
|
128
|
+
assert.equal(duck(s).posture, 'jump', 'the monster ducked while airborne');
|
|
129
|
+
|
|
130
|
+
for (let i = 0; i < DEFAULTS.jumpSteps; i++) s = step(s, s.nextStepAt);
|
|
131
|
+
assert.equal(s.posture, 'run', 'the jump never ended');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('three hits end the run, and nothing moves afterwards', () => {
|
|
135
|
+
let s = createGame({seed: 6, now: 0});
|
|
136
|
+
for (let i = 0; i < 3; i++) {
|
|
137
|
+
s = {...s, track: [{kind: 'obstacle', what: 'fork', lane: LOW}, ...s.track.slice(1)]};
|
|
138
|
+
s = step(s, s.nextStepAt);
|
|
139
|
+
}
|
|
140
|
+
assert.equal(s.over, true);
|
|
141
|
+
assert.equal(s.lives, 0);
|
|
142
|
+
assert.equal(step(s, s.nextStepAt + 10000).distance, s.distance);
|
|
143
|
+
assert.equal(jump(s).posture, s.posture, 'the monster jumped after the run ended');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('nothing happens between steps', () => {
|
|
147
|
+
const s = createGame({seed: 7, now: 0});
|
|
148
|
+
const early = step(s, s.nextStepAt - 1);
|
|
149
|
+
assert.equal(early.distance, 0);
|
|
150
|
+
assert.equal(step(s, s.nextStepAt).distance, 1);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('the run speeds up with the score, down to a floor a person can react in', () => {
|
|
154
|
+
const fresh = createGame({seed: 8, now: 0});
|
|
155
|
+
assert.equal(stepMs(fresh), DEFAULTS.stepStartMs);
|
|
156
|
+
assert.ok(stepMs({...fresh, score: 50}) < DEFAULTS.stepStartMs);
|
|
157
|
+
assert.equal(stepMs({...fresh, score: 9000}), DEFAULTS.stepFloorMs);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('a player who reads the track survives a long run', () => {
|
|
161
|
+
for (const seed of [11, 12, 13]) {
|
|
162
|
+
const s = playWell(createGame({seed, now: 0}), 250);
|
|
163
|
+
assert.equal(s.over, false, `seed ${seed}: playing correctly still ended the run`);
|
|
164
|
+
assert.equal(s.lives, DEFAULTS.lives);
|
|
165
|
+
assert.ok(s.distance > 200);
|
|
166
|
+
assert.ok(s.score > 0);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('the track deals both kinds of obstacle and something worth collecting', () => {
|
|
171
|
+
const seen = new Set();
|
|
172
|
+
let s = createGame({seed: 21, now: 0});
|
|
173
|
+
let t = s.nextStepAt;
|
|
174
|
+
for (let i = 0; i < 400 && !s.over; i++) {
|
|
175
|
+
const incoming = s.track[0];
|
|
176
|
+
if (incoming) seen.add(incoming.kind === 'obstacle' ? `o:${incoming.lane}` : 'part');
|
|
177
|
+
if (incoming && incoming.kind === 'obstacle') s = incoming.lane === LOW ? jump(s) : duck(s);
|
|
178
|
+
s = step(s, t);
|
|
179
|
+
t = s.nextStepAt;
|
|
180
|
+
}
|
|
181
|
+
assert.ok(seen.has(`o:${LOW}`), 'nothing ever arrived on the ground');
|
|
182
|
+
assert.ok(seen.has(`o:${HIGH}`), 'nothing ever arrived overhead');
|
|
183
|
+
assert.ok(seen.has('part'), 'there was never anything to collect');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('every obstacle and part the generator makes is one the game knows', () => {
|
|
187
|
+
const obstacleKeys = OBSTACLES.map((o) => o.key);
|
|
188
|
+
let s = createGame({seed: 31, now: 0});
|
|
189
|
+
let t = s.nextStepAt;
|
|
190
|
+
for (let i = 0; i < 300 && !s.over; i++) {
|
|
191
|
+
for (const cell of s.track) {
|
|
192
|
+
if (!cell) continue;
|
|
193
|
+
if (cell.kind === 'obstacle') {
|
|
194
|
+
assert.ok(obstacleKeys.includes(cell.what), `unknown obstacle ${cell.what}`);
|
|
195
|
+
assert.ok([LOW, HIGH].includes(cell.lane));
|
|
196
|
+
} else {
|
|
197
|
+
assert.ok(PARTS.includes(cell.what), `unknown part ${cell.what}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const incoming = s.track[0];
|
|
201
|
+
if (incoming && incoming.kind === 'obstacle') s = incoming.lane === LOW ? jump(s) : duck(s);
|
|
202
|
+
s = step(s, t);
|
|
203
|
+
t = s.nextStepAt;
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('the same seed runs the same track, a different one does not', () => {
|
|
208
|
+
const shape = (seed) => createGame({seed, now: 0}).track
|
|
209
|
+
.map((c) => (c ? `${c.kind[0]}${c.lane[0]}` : '-')).join('');
|
|
210
|
+
assert.equal(shape(41), shape(41));
|
|
211
|
+
assert.notEqual(shape(41), shape(42));
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('the summary reads as a sentence in both locales', () => {
|
|
215
|
+
const s = {distance: 240, collected: 9};
|
|
216
|
+
assert.match(summarise(s, 'en'), /240 strides · 9 parts/);
|
|
217
|
+
assert.match(summarise(s, 'nl'), /240 stappen · 9 onderdelen/);
|
|
218
|
+
});
|