@conduction/docusaurus-preset 3.42.0 → 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/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 +1 -0
package/package.json
CHANGED
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Monster run — the rules, with no DOM and no clock of its own.
|
|
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 assembles goes for a run:
|
|
6
|
+
* jump the forks, duck the patch sets, and pick up the parts it was
|
|
7
|
+
* stitched together from.
|
|
8
|
+
*
|
|
9
|
+
* The joke is the obstacles. A fork is what a maintenance bill looks
|
|
10
|
+
* like, a patch set is what you carry forever, and a release train
|
|
11
|
+
* arrives whether you are ready or not. The things worth collecting
|
|
12
|
+
* are the ones the post argues are already shared: a token, a
|
|
13
|
+
* protocol, a component.
|
|
14
|
+
*
|
|
15
|
+
* Two inputs, jump and duck, which is what makes it a runner rather
|
|
16
|
+
* than a fourth game about picking the right answer. Time and
|
|
17
|
+
* randomness are injected; the component owns the clock.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/* Where a thing sits, which decides what gets you past it. */
|
|
21
|
+
export const LOW = 'low'; // on the ground: jump it
|
|
22
|
+
export const HIGH = 'high'; // overhead: duck under it
|
|
23
|
+
|
|
24
|
+
export const OBSTACLES = [
|
|
25
|
+
{key: 'fork', lane: LOW},
|
|
26
|
+
{key: 'patchset', lane: HIGH},
|
|
27
|
+
{key: 'releaseTrain', lane: LOW},
|
|
28
|
+
{key: 'designLanguage', lane: HIGH},
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export const PARTS = ['token', 'protocol', 'component'];
|
|
32
|
+
|
|
33
|
+
/* The track is a line of columns. The monster stands at column 0 and
|
|
34
|
+
the world walks towards it. */
|
|
35
|
+
export const TRACK = 14;
|
|
36
|
+
|
|
37
|
+
export const DEFAULTS = {
|
|
38
|
+
lives: 3,
|
|
39
|
+
stepStartMs: 300,
|
|
40
|
+
stepFloorMs: 120,
|
|
41
|
+
rampPerPoint: 0.9,
|
|
42
|
+
/* Airborne and crouched both last a couple of steps, so timing is a
|
|
43
|
+
decision rather than a reflex: commit early and you land in it. */
|
|
44
|
+
jumpSteps: 2,
|
|
45
|
+
duckSteps: 2,
|
|
46
|
+
obstacleChance: 0.42,
|
|
47
|
+
partChance: 0.26,
|
|
48
|
+
/* Never two obstacles back to back: the monster cannot be in the air
|
|
49
|
+
and crouched at once, and a run that kills you whatever you do is
|
|
50
|
+
the fastest way to make a player stop. */
|
|
51
|
+
minGap: 2,
|
|
52
|
+
/* The first stretch is always empty. The track is filled before the
|
|
53
|
+
run starts so obstacles are visible before they arrive, but that
|
|
54
|
+
fill can put one at the monster's feet: the run then costs a life
|
|
55
|
+
in its first second, before the player has touched a key. */
|
|
56
|
+
leadIn: 5,
|
|
57
|
+
pointsPerStep: 1,
|
|
58
|
+
pointsPerPart: 6,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function mulberry32(seed) {
|
|
62
|
+
let a = seed >>> 0;
|
|
63
|
+
return function random() {
|
|
64
|
+
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
|
65
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
66
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
67
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** What the far end of the track should hold next. */
|
|
72
|
+
function spawn(state) {
|
|
73
|
+
if (state.sinceObstacle < state.cfg.minGap) {
|
|
74
|
+
/* Too soon for another obstacle, but a part is welcome. */
|
|
75
|
+
if (state.random() < state.cfg.partChance) {
|
|
76
|
+
return {kind: 'part', what: PARTS[Math.floor(state.random() * PARTS.length)], lane: state.random() < 0.5 ? LOW : HIGH};
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
if (state.random() < state.cfg.obstacleChance) {
|
|
81
|
+
const pick = OBSTACLES[Math.floor(state.random() * OBSTACLES.length)];
|
|
82
|
+
return {kind: 'obstacle', what: pick.key, lane: pick.lane};
|
|
83
|
+
}
|
|
84
|
+
if (state.random() < state.cfg.partChance) {
|
|
85
|
+
return {kind: 'part', what: PARTS[Math.floor(state.random() * PARTS.length)], lane: state.random() < 0.5 ? LOW : HIGH};
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function createGame({seed = Date.now(), now = 0, config = {}} = {}) {
|
|
91
|
+
const cfg = {...DEFAULTS, ...config};
|
|
92
|
+
let state = {
|
|
93
|
+
cfg,
|
|
94
|
+
random: mulberry32(seed),
|
|
95
|
+
track: Array.from({length: TRACK}, () => null),
|
|
96
|
+
posture: 'run', // run | jump | duck
|
|
97
|
+
postureFor: 0, // steps left in the current posture
|
|
98
|
+
lives: cfg.lives,
|
|
99
|
+
score: 0,
|
|
100
|
+
distance: 0,
|
|
101
|
+
collected: 0,
|
|
102
|
+
hits: 0,
|
|
103
|
+
sinceObstacle: cfg.minGap,
|
|
104
|
+
nextStepAt: now + cfg.stepStartMs,
|
|
105
|
+
last: null,
|
|
106
|
+
over: false,
|
|
107
|
+
};
|
|
108
|
+
/* Fill the track so the first obstacles are visible before they
|
|
109
|
+
arrive, rather than appearing under the monster's feet. */
|
|
110
|
+
for (let i = 0; i < TRACK; i++) {
|
|
111
|
+
const cell = spawn(state);
|
|
112
|
+
state = {
|
|
113
|
+
...state,
|
|
114
|
+
track: [...state.track.slice(1), cell],
|
|
115
|
+
sinceObstacle: cell && cell.kind === 'obstacle' ? 0 : state.sinceObstacle + 1,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/* Then clear the run-up, so the first thing to answer is one the
|
|
119
|
+
player watched coming. */
|
|
120
|
+
const track = [...state.track];
|
|
121
|
+
for (let i = 0; i < Math.min(cfg.leadIn, track.length); i++) track[i] = null;
|
|
122
|
+
return {...state, track};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function stepMs(state) {
|
|
126
|
+
return Math.max(state.cfg.stepFloorMs, state.cfg.stepStartMs - state.score * state.cfg.rampPerPoint);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Jump, if the monster has its feet on the ground. */
|
|
130
|
+
export function jump(state) {
|
|
131
|
+
if (state.over || state.posture !== 'run') return state;
|
|
132
|
+
return {...state, posture: 'jump', postureFor: state.cfg.jumpSteps};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Duck, same rule: no changing your mind mid-air. */
|
|
136
|
+
export function duck(state) {
|
|
137
|
+
if (state.over || state.posture !== 'run') return state;
|
|
138
|
+
return {...state, posture: 'duck', postureFor: state.cfg.duckSteps};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Does the current posture get the monster past this cell? */
|
|
142
|
+
export function clears(posture, cell) {
|
|
143
|
+
if (!cell || cell.kind !== 'obstacle') return true;
|
|
144
|
+
if (cell.lane === LOW) return posture === 'jump';
|
|
145
|
+
return posture === 'duck';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Advance one step: the world moves one column closer, the monster
|
|
150
|
+
* meets whatever arrives at its own column, and its posture wears off.
|
|
151
|
+
*/
|
|
152
|
+
export function step(state, now) {
|
|
153
|
+
if (state.over || now < state.nextStepAt) return state;
|
|
154
|
+
|
|
155
|
+
const arriving = state.track[0];
|
|
156
|
+
let next = {...state, distance: state.distance + 1};
|
|
157
|
+
|
|
158
|
+
if (arriving && arriving.kind === 'obstacle' && !clears(state.posture, arriving)) {
|
|
159
|
+
const lives = next.lives - 1;
|
|
160
|
+
next = {...next, lives, hits: next.hits + 1, last: {result: 'hit', what: arriving.what, at: now}, over: lives <= 0};
|
|
161
|
+
} else if (arriving && arriving.kind === 'part') {
|
|
162
|
+
/* A part is taken by running into it, whatever the posture: the
|
|
163
|
+
monster is made of these, it does not have to reach. */
|
|
164
|
+
next = {
|
|
165
|
+
...next,
|
|
166
|
+
score: next.score + next.cfg.pointsPerPart,
|
|
167
|
+
collected: next.collected + 1,
|
|
168
|
+
last: {result: 'part', what: arriving.what, at: now},
|
|
169
|
+
};
|
|
170
|
+
} else {
|
|
171
|
+
next = {...next, score: next.score + next.cfg.pointsPerStep, last: {result: 'clear', at: now}};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (next.over) return next;
|
|
175
|
+
|
|
176
|
+
const postureFor = Math.max(0, next.postureFor - 1);
|
|
177
|
+
const cell = spawn(next);
|
|
178
|
+
return {
|
|
179
|
+
...next,
|
|
180
|
+
posture: postureFor === 0 ? 'run' : next.posture,
|
|
181
|
+
postureFor,
|
|
182
|
+
track: [...next.track.slice(1), cell],
|
|
183
|
+
sinceObstacle: cell && cell.kind === 'obstacle' ? 0 : next.sinceObstacle + 1,
|
|
184
|
+
nextStepAt: now + stepMs(next),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The line that goes on the game-over card and into the post. */
|
|
189
|
+
export function summarise(state, locale = 'en') {
|
|
190
|
+
const n = (v) => Number(v || 0).toLocaleString(locale);
|
|
191
|
+
return locale === 'nl'
|
|
192
|
+
? `${n(state.distance)} stappen · ${n(state.collected)} onderdelen`
|
|
193
|
+
: `${n(state.distance)} strides · ${n(state.collected)} parts`;
|
|
194
|
+
}
|
package/src/components/index.js
CHANGED
|
@@ -65,6 +65,7 @@ export {default as DeadlineDefender} from './DeadlineDefender/DeadlineDefender.j
|
|
|
65
65
|
export {default as BlueprintRush} from './BlueprintRush/BlueprintRush.jsx';
|
|
66
66
|
export {default as RecordRun} from './RecordRun/RecordRun.jsx';
|
|
67
67
|
export {default as HiddenGame} from './HiddenGame/HiddenGame.jsx';
|
|
68
|
+
export {default as MonsterRun} from './MonsterRun/MonsterRun.jsx';
|
|
68
69
|
export {default as LockPick} from './LockPick/LockPick.jsx';
|
|
69
70
|
export {default as PaintByTokens} from './PaintByTokens/PaintByTokens.jsx';
|
|
70
71
|
export {default as Redaction} from './Redaction/Redaction.jsx';
|