@conduction/docusaurus-preset 3.42.0 → 3.44.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/DiceDuel/DiceDuel.jsx +190 -0
- package/src/components/DiceDuel/DiceDuel.module.css +191 -0
- package/src/components/DiceDuel/__tests__/engine.test.js +159 -0
- package/src/components/DiceDuel/engine.js +182 -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/PipeFit/PipeFit.jsx +221 -0
- package/src/components/PipeFit/PipeFit.module.css +203 -0
- package/src/components/PipeFit/__tests__/engine.test.js +176 -0
- package/src/components/PipeFit/engine.js +178 -0
- package/src/components/Reconcile/Reconcile.jsx +240 -0
- package/src/components/Reconcile/Reconcile.module.css +217 -0
- package/src/components/Reconcile/__tests__/engine.test.js +168 -0
- package/src/components/Reconcile/engine.js +228 -0
- package/src/components/index.js +4 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <PipeFit />
|
|
3
|
+
*
|
|
4
|
+
* Integriq's game. A record is leaving one system for another and the
|
|
5
|
+
* route between them is half built. Turn each connector until the line
|
|
6
|
+
* runs end to end, before the payload arrives and finds a gap.
|
|
7
|
+
*
|
|
8
|
+
* Turning a connector moves both its openings at once, so fixing the
|
|
9
|
+
* join on one side can break the join on the other. That is the whole
|
|
10
|
+
* puzzle, and it is what connecting two systems actually feels like.
|
|
11
|
+
*
|
|
12
|
+
* The rules live in ./engine.js with no DOM and no clock.
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
*
|
|
16
|
+
* <PipeFit />
|
|
17
|
+
*
|
|
18
|
+
* Fires the shared `connext:gameend` event on game over, and listens
|
|
19
|
+
* for `connext:gamereplay`.
|
|
20
|
+
*
|
|
21
|
+
* Accessibility: every connector is a button that says which openings
|
|
22
|
+
* it currently has and whether it meets its neighbour, so the route
|
|
23
|
+
* can be read and solved without seeing the diagram.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import React, {useCallback, useEffect, useRef, useState} from 'react';
|
|
27
|
+
import {translate} from '@docusaurus/Translate';
|
|
28
|
+
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
|
29
|
+
import {createGame, turn, step, openings, connected, remaining, summarise} from './engine';
|
|
30
|
+
import styles from './PipeFit.module.css';
|
|
31
|
+
|
|
32
|
+
const GAME_ID = 'pipe-fit';
|
|
33
|
+
const TICK_MS = 100;
|
|
34
|
+
|
|
35
|
+
/* Ports are heights, and heights have names: a route read aloud as
|
|
36
|
+
"top meets middle" is one you can solve with your eyes shut. */
|
|
37
|
+
function portName(port) {
|
|
38
|
+
if (port === 0) {
|
|
39
|
+
return translate({id: 'preset.pipeFit.port.top', message: 'top', description: 'The upper opening of a connector'});
|
|
40
|
+
}
|
|
41
|
+
if (port === 1) {
|
|
42
|
+
return translate({id: 'preset.pipeFit.port.middle', message: 'middle', description: 'The middle opening of a connector'});
|
|
43
|
+
}
|
|
44
|
+
return translate({id: 'preset.pipeFit.port.bottom', message: 'bottom', description: 'The lower opening of a connector'});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default function PipeFit({className}) {
|
|
48
|
+
const {i18n} = useDocusaurusContext();
|
|
49
|
+
const locale = (i18n && i18n.currentLocale) || 'en';
|
|
50
|
+
|
|
51
|
+
const [game, setGame] = useState(null);
|
|
52
|
+
const [left, setLeft] = useState(1);
|
|
53
|
+
const gameRef = useRef(null);
|
|
54
|
+
const startedAtRef = useRef(0);
|
|
55
|
+
const endedRef = useRef(false);
|
|
56
|
+
|
|
57
|
+
const running = Boolean(game) && !game.over;
|
|
58
|
+
const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now()) - startedAtRef.current;
|
|
59
|
+
|
|
60
|
+
const begin = useCallback(() => {
|
|
61
|
+
endedRef.current = false;
|
|
62
|
+
startedAtRef.current = (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
|
63
|
+
const fresh = createGame({seed: Math.floor(Math.random() * 2 ** 31), now: 0});
|
|
64
|
+
gameRef.current = fresh;
|
|
65
|
+
setGame(fresh);
|
|
66
|
+
setLeft(1);
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (!running) return undefined;
|
|
71
|
+
const id = setInterval(() => {
|
|
72
|
+
const t = now();
|
|
73
|
+
const next = step(gameRef.current, t);
|
|
74
|
+
gameRef.current = next;
|
|
75
|
+
setGame(next);
|
|
76
|
+
setLeft(remaining(next, t));
|
|
77
|
+
}, TICK_MS);
|
|
78
|
+
return () => clearInterval(id);
|
|
79
|
+
}, [running]);
|
|
80
|
+
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (!game || !game.over || endedRef.current) return;
|
|
83
|
+
endedRef.current = true;
|
|
84
|
+
if (typeof window === 'undefined') return;
|
|
85
|
+
window.dispatchEvent(new CustomEvent('connext:gameend', {
|
|
86
|
+
detail: {
|
|
87
|
+
id: GAME_ID,
|
|
88
|
+
won: false,
|
|
89
|
+
score: game.score,
|
|
90
|
+
summary: summarise(game, locale),
|
|
91
|
+
title: translate({id: 'preset.pipeFit.over.title', message: 'It went nowhere.', description: 'Headline on the game-over dialog after a pipe-fit run'}),
|
|
92
|
+
subtitle: translate({id: 'preset.pipeFit.over.subtitle', message: 'Three payloads into a gap. Somebody will notice next quarter.', description: 'Subtitle on the game-over dialog after a pipe-fit run'}),
|
|
93
|
+
},
|
|
94
|
+
}));
|
|
95
|
+
}, [game, locale]);
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (typeof window === 'undefined') return undefined;
|
|
99
|
+
const onReplay = (e) => { if (e.detail && e.detail.id === GAME_ID) begin(); };
|
|
100
|
+
window.addEventListener('connext:gamereplay', onReplay);
|
|
101
|
+
return () => window.removeEventListener('connext:gamereplay', onReplay);
|
|
102
|
+
}, [begin]);
|
|
103
|
+
|
|
104
|
+
const rotate = useCallback((index) => {
|
|
105
|
+
if (!gameRef.current || gameRef.current.over) return;
|
|
106
|
+
const t = now();
|
|
107
|
+
const next = turn(gameRef.current, index, t);
|
|
108
|
+
gameRef.current = next;
|
|
109
|
+
setGame(next);
|
|
110
|
+
setLeft(remaining(next, t));
|
|
111
|
+
}, []);
|
|
112
|
+
|
|
113
|
+
const route = game ? game.route : null;
|
|
114
|
+
const last = game ? game.last : null;
|
|
115
|
+
const pct = Math.round(left * 100);
|
|
116
|
+
|
|
117
|
+
/* What each piece is carrying in, so a join can be judged. */
|
|
118
|
+
const carries = [];
|
|
119
|
+
if (route) {
|
|
120
|
+
let carry = route.source;
|
|
121
|
+
for (const piece of route.pieces) {
|
|
122
|
+
carries.push(carry);
|
|
123
|
+
carry = openings(piece).right;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<section className={[styles.pf, className].filter(Boolean).join(' ')} aria-labelledby="pipe-fit-title">
|
|
129
|
+
<header className={styles.head}>
|
|
130
|
+
<div>
|
|
131
|
+
<p className={styles.eyebrow}>
|
|
132
|
+
{translate({id: 'preset.pipeFit.eyebrow', message: 'Mini-game', description: 'Eyebrow above the pipe-fit game on a product page'})}
|
|
133
|
+
</p>
|
|
134
|
+
<h3 className={styles.title} id="pipe-fit-title">
|
|
135
|
+
{translate({id: 'preset.pipeFit.title', message: 'Make the connection', description: 'Name of the Integriq mini-game'})}
|
|
136
|
+
</h3>
|
|
137
|
+
<p className={styles.lede}>
|
|
138
|
+
{translate({id: 'preset.pipeFit.lede', message: 'A record is on its way from one system to another and the route is half built. Turn the connectors until the line runs end to end. Turning one moves both its openings, which is the whole problem.', description: 'One-line explanation of the pipe-fit rules'})}
|
|
139
|
+
</p>
|
|
140
|
+
</div>
|
|
141
|
+
<div className={styles.hud} role="status" aria-live="polite">
|
|
142
|
+
<span className={styles.hudPill}>
|
|
143
|
+
{translate({id: 'preset.pipeFit.hud.score', message: 'Score {score}', description: 'Score readout on the pipe-fit HUD'}, {score: Number(game ? game.score : 0).toLocaleString(locale)})}
|
|
144
|
+
</span>
|
|
145
|
+
<span className={styles.hudPill}>
|
|
146
|
+
{translate({id: 'preset.pipeFit.hud.lives', message: 'Payloads left {lives}', description: 'Remaining-lives readout on the pipe-fit HUD'}, {lives: game ? game.lives : 3})}
|
|
147
|
+
</span>
|
|
148
|
+
</div>
|
|
149
|
+
</header>
|
|
150
|
+
|
|
151
|
+
{route ? (
|
|
152
|
+
<>
|
|
153
|
+
<div className={styles.route}>
|
|
154
|
+
<span className={[styles.end, styles[`port-${route.source}`]].join(' ')}>
|
|
155
|
+
{translate({id: 'preset.pipeFit.source', message: 'Source', description: 'The system a record leaves in the pipe-fit game'})}
|
|
156
|
+
</span>
|
|
157
|
+
|
|
158
|
+
{route.pieces.map((piece, i) => {
|
|
159
|
+
const {left: inPort, right: outPort} = openings(piece);
|
|
160
|
+
const joined = inPort === carries[i];
|
|
161
|
+
return (
|
|
162
|
+
<button
|
|
163
|
+
key={i}
|
|
164
|
+
type="button"
|
|
165
|
+
className={[styles.piece, joined ? styles.joined : styles.gap].join(' ')}
|
|
166
|
+
onClick={() => rotate(i)}
|
|
167
|
+
disabled={!running}
|
|
168
|
+
aria-label={translate(
|
|
169
|
+
{id: 'preset.pipeFit.piece', message: 'Connector {n}: opens {in} to {out}, {state}. Turn it.', description: 'Accessible label for one connector. {in} and {out} are openings, {state} says whether it meets the piece before it.'},
|
|
170
|
+
{
|
|
171
|
+
n: i + 1,
|
|
172
|
+
in: portName(inPort),
|
|
173
|
+
out: portName(outPort),
|
|
174
|
+
state: joined
|
|
175
|
+
? translate({id: 'preset.pipeFit.piece.joined', message: 'meets the one before it', description: 'State of a connector that lines up'})
|
|
176
|
+
: translate({id: 'preset.pipeFit.piece.gap', message: 'does not meet the one before it', description: 'State of a connector that does not line up'}),
|
|
177
|
+
},
|
|
178
|
+
)}>
|
|
179
|
+
<span className={[styles.mouth, styles[`port-${inPort}`]].join(' ')} aria-hidden="true" />
|
|
180
|
+
<span className={styles.barrel} aria-hidden="true" />
|
|
181
|
+
<span className={[styles.mouth, styles[`port-${outPort}`]].join(' ')} aria-hidden="true" />
|
|
182
|
+
</button>
|
|
183
|
+
);
|
|
184
|
+
})}
|
|
185
|
+
|
|
186
|
+
<span className={[styles.end, styles[`port-${route.target}`]].join(' ')}>
|
|
187
|
+
{translate({id: 'preset.pipeFit.target', message: 'Consumer', description: 'The system a record arrives at in the pipe-fit game'})}
|
|
188
|
+
</span>
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
<div
|
|
192
|
+
className={styles.clock}
|
|
193
|
+
role="progressbar"
|
|
194
|
+
aria-valuemin={0}
|
|
195
|
+
aria-valuemax={100}
|
|
196
|
+
aria-valuenow={pct}
|
|
197
|
+
aria-label={translate({id: 'preset.pipeFit.clock', message: 'Time before the payload arrives', description: 'Accessible name of the pipe-fit countdown'})}>
|
|
198
|
+
<div className={[styles.clockFill, left < 0.3 && styles.clockLow].filter(Boolean).join(' ')} style={{width: `${pct}%`}} />
|
|
199
|
+
</div>
|
|
200
|
+
</>
|
|
201
|
+
) : (
|
|
202
|
+
<p className={styles.idle}>
|
|
203
|
+
{translate({id: 'preset.pipeFit.idle', message: 'Two systems, and nothing in between them yet.', description: 'Placeholder before the pipe-fit game starts'})}
|
|
204
|
+
</p>
|
|
205
|
+
)}
|
|
206
|
+
|
|
207
|
+
<footer className={styles.foot}>
|
|
208
|
+
<button type="button" className={styles.start} onClick={begin}>
|
|
209
|
+
{game
|
|
210
|
+
? translate({id: 'preset.pipeFit.restart', message: 'Restart', description: 'Button that restarts the pipe-fit game'})
|
|
211
|
+
: translate({id: 'preset.pipeFit.start', message: 'Send it', description: 'Button that starts the pipe-fit game'})}
|
|
212
|
+
</button>
|
|
213
|
+
<p className={styles.hint} role="status" aria-live="polite">
|
|
214
|
+
{last && last.result === 'connected' && translate({id: 'preset.pipeFit.feedback.connected', message: 'Through. The next one is longer.', description: 'Feedback after completing a route'})}
|
|
215
|
+
{last && last.result === 'spilled' && translate({id: 'preset.pipeFit.feedback.spilled', message: 'The payload arrived and found a gap.', description: 'Feedback after the clock runs out'})}
|
|
216
|
+
{(!last || last.result === 'turned') && translate({id: 'preset.pipeFit.hint', message: 'Click a connector to turn it. Both of its openings move together.', description: 'Hint under the pipe-fit route'})}
|
|
217
|
+
</p>
|
|
218
|
+
</footer>
|
|
219
|
+
</section>
|
|
220
|
+
);
|
|
221
|
+
}
|