@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,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dice duel — the rules, with no DOM and no clock of its own.
|
|
3
|
+
*
|
|
4
|
+
* Larpinq's game, and the one app where a game needs no excuse. A
|
|
5
|
+
* monster blocks the path. You have a handful of dice and a choice
|
|
6
|
+
* every round: swing with what you rolled, or push your luck and
|
|
7
|
+
* reroll the weak ones.
|
|
8
|
+
*
|
|
9
|
+
* The whole game is that choice. A roll you keep is certain and
|
|
10
|
+
* usually small; a reroll is the only way to a big hit and the only
|
|
11
|
+
* way to lose the round. Nothing here is a reflex, so it is the one
|
|
12
|
+
* game on the site you can play badly while thinking hard.
|
|
13
|
+
*
|
|
14
|
+
* There is no clock anywhere in it: pushing your luck is not a thing
|
|
15
|
+
* you should be hurried through.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const DICE = 4;
|
|
19
|
+
export const FACES = 6;
|
|
20
|
+
|
|
21
|
+
export const MONSTERS = [
|
|
22
|
+
{key: 'goblin', hp: 12, bite: 3},
|
|
23
|
+
{key: 'troll', hp: 20, bite: 5},
|
|
24
|
+
{key: 'wyrm', hp: 30, bite: 7},
|
|
25
|
+
{key: 'lich', hp: 42, bite: 9},
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export const DEFAULTS = {
|
|
29
|
+
hearts: 3,
|
|
30
|
+
/* A reroll costs nothing but the round's safety: the monster bites
|
|
31
|
+
when you reroll and roll worse than you already had. That is the
|
|
32
|
+
push-your-luck deal, and it is the only way to be hurt. */
|
|
33
|
+
rerollsPerRound: 2,
|
|
34
|
+
/* Sixes are worth more than their pips, so a good roll feels good
|
|
35
|
+
rather than merely numerical. */
|
|
36
|
+
critBonus: 3,
|
|
37
|
+
pointsPerKill: 25,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function mulberry32(seed) {
|
|
41
|
+
let a = seed >>> 0;
|
|
42
|
+
return function random() {
|
|
43
|
+
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
|
44
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
45
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
46
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function rollDie(state) {
|
|
51
|
+
return 1 + Math.floor(state.random() * FACES);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function rollAll(state) {
|
|
55
|
+
return Array.from({length: DICE}, () => rollDie(state));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** What a set of dice is worth: pips, plus a bonus for every six. */
|
|
59
|
+
export function damageOf(dice) {
|
|
60
|
+
const pips = dice.reduce((sum, d) => sum + d, 0);
|
|
61
|
+
const sixes = dice.filter((d) => d === FACES).length;
|
|
62
|
+
return pips + sixes * DEFAULTS.critBonus;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function nextMonster(state, index) {
|
|
66
|
+
const spec = MONSTERS[index % MONSTERS.length];
|
|
67
|
+
return {key: spec.key, hp: spec.hp, maxHp: spec.hp, bite: spec.bite, index};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function createGame({seed = Date.now(), config = {}} = {}) {
|
|
71
|
+
const cfg = {...DEFAULTS, ...config};
|
|
72
|
+
const base = {
|
|
73
|
+
cfg,
|
|
74
|
+
random: mulberry32(seed),
|
|
75
|
+
hearts: cfg.hearts,
|
|
76
|
+
score: 0,
|
|
77
|
+
felled: 0,
|
|
78
|
+
rounds: 0,
|
|
79
|
+
last: null,
|
|
80
|
+
over: false,
|
|
81
|
+
};
|
|
82
|
+
const withMonster = {...base, monster: nextMonster(base, 0)};
|
|
83
|
+
return {
|
|
84
|
+
...withMonster,
|
|
85
|
+
dice: rollAll(withMonster),
|
|
86
|
+
kept: Array.from({length: DICE}, () => false),
|
|
87
|
+
rerollsLeft: cfg.rerollsPerRound,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Hold or release one die between rerolls. */
|
|
92
|
+
export function toggleKeep(state, index) {
|
|
93
|
+
if (state.over) return state;
|
|
94
|
+
if (!Number.isInteger(index) || index < 0 || index >= DICE) return state;
|
|
95
|
+
const kept = [...state.kept];
|
|
96
|
+
kept[index] = !kept[index];
|
|
97
|
+
return {...state, kept};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Reroll everything not held.
|
|
102
|
+
*
|
|
103
|
+
* Rolling worse than you had is what the monster punishes: that is the
|
|
104
|
+
* push, and without it holding dice would be free and the game would
|
|
105
|
+
* have no decision in it.
|
|
106
|
+
*/
|
|
107
|
+
export function reroll(state) {
|
|
108
|
+
if (state.over || state.rerollsLeft <= 0) return state;
|
|
109
|
+
if (state.kept.every(Boolean)) return state;
|
|
110
|
+
|
|
111
|
+
const before = damageOf(state.dice);
|
|
112
|
+
const dice = state.dice.map((die, i) => (state.kept[i] ? die : rollDie(state)));
|
|
113
|
+
const after = damageOf(dice);
|
|
114
|
+
|
|
115
|
+
if (after >= before) {
|
|
116
|
+
return {
|
|
117
|
+
...state,
|
|
118
|
+
dice,
|
|
119
|
+
rerollsLeft: state.rerollsLeft - 1,
|
|
120
|
+
last: {result: 'rerollUp', from: before, to: after},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const hearts = state.hearts - 1;
|
|
125
|
+
return {
|
|
126
|
+
...state,
|
|
127
|
+
dice,
|
|
128
|
+
rerollsLeft: state.rerollsLeft - 1,
|
|
129
|
+
hearts,
|
|
130
|
+
last: {result: 'bitten', from: before, to: after, by: state.monster.key},
|
|
131
|
+
over: hearts <= 0,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Swing with what is on the table.
|
|
137
|
+
*
|
|
138
|
+
* Always safe, always ends the round. A monster that survives heals
|
|
139
|
+
* nothing: the damage stays on it, so a careful player wins slowly and
|
|
140
|
+
* a lucky one wins fast.
|
|
141
|
+
*/
|
|
142
|
+
export function strike(state) {
|
|
143
|
+
if (state.over) return state;
|
|
144
|
+
|
|
145
|
+
const damage = damageOf(state.dice);
|
|
146
|
+
const hp = state.monster.hp - damage;
|
|
147
|
+
const rounds = state.rounds + 1;
|
|
148
|
+
|
|
149
|
+
if (hp > 0) {
|
|
150
|
+
const wounded = {...state, monster: {...state.monster, hp}, rounds, score: state.score + damage};
|
|
151
|
+
return {
|
|
152
|
+
...wounded,
|
|
153
|
+
dice: rollAll(wounded),
|
|
154
|
+
kept: Array.from({length: DICE}, () => false),
|
|
155
|
+
rerollsLeft: wounded.cfg.rerollsPerRound,
|
|
156
|
+
last: {result: 'hit', damage, left: hp},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const felled = {
|
|
161
|
+
...state,
|
|
162
|
+
rounds,
|
|
163
|
+
score: state.score + damage + state.cfg.pointsPerKill,
|
|
164
|
+
felled: state.felled + 1,
|
|
165
|
+
last: {result: 'felled', damage, what: state.monster.key},
|
|
166
|
+
};
|
|
167
|
+
const withMonster = {...felled, monster: nextMonster(felled, felled.monster.index + 1)};
|
|
168
|
+
return {
|
|
169
|
+
...withMonster,
|
|
170
|
+
dice: rollAll(withMonster),
|
|
171
|
+
kept: Array.from({length: DICE}, () => false),
|
|
172
|
+
rerollsLeft: withMonster.cfg.rerollsPerRound,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The line that goes on the game-over card and into the post. */
|
|
177
|
+
export function summarise(state, locale = 'en') {
|
|
178
|
+
const n = (v) => Number(v || 0).toLocaleString(locale);
|
|
179
|
+
return locale === 'nl'
|
|
180
|
+
? `${n(state.felled)} monsters geveld · ${n(state.rounds)} beurten`
|
|
181
|
+
: `${n(state.felled)} monsters felled · ${n(state.rounds)} rounds`;
|
|
182
|
+
}
|
|
@@ -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
|
+
}
|