@conduction/docusaurus-preset 3.43.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/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 +3 -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,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
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <PipeFit /> styles.
|
|
3
|
+
*
|
|
4
|
+
* A line of connectors read left to right, each drawn as two openings
|
|
5
|
+
* and a barrel. Tokens only, one accent on a join that does not meet.
|
|
6
|
+
* Every state is also in the button's label, so the route is solvable
|
|
7
|
+
* without the picture.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
.pf {
|
|
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: 62ch;
|
|
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
|
+
.route {
|
|
68
|
+
display: flex;
|
|
69
|
+
align-items: stretch;
|
|
70
|
+
gap: 4px;
|
|
71
|
+
padding: 16px 12px;
|
|
72
|
+
border-radius: var(--radius-md);
|
|
73
|
+
background: var(--c-cobalt-50);
|
|
74
|
+
max-width: 720px;
|
|
75
|
+
overflow-x: auto;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.end {
|
|
79
|
+
position: relative;
|
|
80
|
+
flex-shrink: 0;
|
|
81
|
+
width: 78px;
|
|
82
|
+
display: flex;
|
|
83
|
+
align-items: center;
|
|
84
|
+
justify-content: center;
|
|
85
|
+
padding: 6px;
|
|
86
|
+
border-radius: var(--radius-md);
|
|
87
|
+
background: var(--c-cobalt-900);
|
|
88
|
+
color: white;
|
|
89
|
+
font-family: var(--conduction-typography-font-family-code);
|
|
90
|
+
font-size: 10px;
|
|
91
|
+
text-align: center;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* The three heights an opening can sit at. The end blocks mark theirs
|
|
95
|
+
with a notch, the connectors with their mouths. */
|
|
96
|
+
.end::after {
|
|
97
|
+
content: '';
|
|
98
|
+
position: absolute;
|
|
99
|
+
width: 8px;
|
|
100
|
+
height: 10px;
|
|
101
|
+
background: var(--c-mint-500);
|
|
102
|
+
border-radius: 2px;
|
|
103
|
+
}
|
|
104
|
+
.end.port-0::after { top: 8px; right: -4px; }
|
|
105
|
+
.end.port-1::after { top: 50%; margin-top: -5px; right: -4px; }
|
|
106
|
+
.end.port-2::after { bottom: 8px; right: -4px; }
|
|
107
|
+
.end:last-child::after { right: auto; left: -4px; }
|
|
108
|
+
|
|
109
|
+
.piece {
|
|
110
|
+
position: relative;
|
|
111
|
+
flex: 1;
|
|
112
|
+
min-width: 68px;
|
|
113
|
+
height: 84px;
|
|
114
|
+
border: 1px solid var(--c-cobalt-200);
|
|
115
|
+
border-radius: var(--radius-md);
|
|
116
|
+
background: white;
|
|
117
|
+
cursor: pointer;
|
|
118
|
+
padding: 0;
|
|
119
|
+
transition: border-color 120ms ease, background 120ms ease;
|
|
120
|
+
}
|
|
121
|
+
.piece:hover:not(:disabled) { border-color: var(--c-blue-cobalt); }
|
|
122
|
+
.piece:disabled { cursor: default; opacity: 0.7; }
|
|
123
|
+
.piece:focus-visible { outline: 2px solid var(--c-blue-cobalt); outline-offset: 2px; }
|
|
124
|
+
|
|
125
|
+
.joined { border-color: var(--c-mint-500); }
|
|
126
|
+
.gap { border-color: var(--c-orange-knvb); background: var(--c-cobalt-50); }
|
|
127
|
+
|
|
128
|
+
.mouth {
|
|
129
|
+
position: absolute;
|
|
130
|
+
width: 10px;
|
|
131
|
+
height: 12px;
|
|
132
|
+
border-radius: 2px;
|
|
133
|
+
background: var(--c-blue-cobalt);
|
|
134
|
+
}
|
|
135
|
+
.mouth:first-of-type { left: -1px; }
|
|
136
|
+
.mouth:last-of-type { right: -1px; }
|
|
137
|
+
.mouth.port-0 { top: 10px; }
|
|
138
|
+
.mouth.port-1 { top: 50%; margin-top: -6px; }
|
|
139
|
+
.mouth.port-2 { bottom: 10px; }
|
|
140
|
+
|
|
141
|
+
.barrel {
|
|
142
|
+
position: absolute;
|
|
143
|
+
left: 12px;
|
|
144
|
+
right: 12px;
|
|
145
|
+
top: 50%;
|
|
146
|
+
height: 4px;
|
|
147
|
+
margin-top: -2px;
|
|
148
|
+
border-radius: 2px;
|
|
149
|
+
background: var(--c-cobalt-200);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.clock {
|
|
153
|
+
height: 6px;
|
|
154
|
+
border-radius: var(--radius-pill);
|
|
155
|
+
background: var(--c-cobalt-200);
|
|
156
|
+
overflow: hidden;
|
|
157
|
+
margin-top: 12px;
|
|
158
|
+
max-width: 720px;
|
|
159
|
+
}
|
|
160
|
+
.clockFill {
|
|
161
|
+
height: 100%;
|
|
162
|
+
background: var(--c-mint-500);
|
|
163
|
+
transition: width 100ms linear;
|
|
164
|
+
}
|
|
165
|
+
.clockLow { background: var(--c-orange-knvb); }
|
|
166
|
+
|
|
167
|
+
.idle {
|
|
168
|
+
margin: 0;
|
|
169
|
+
font-size: 14px;
|
|
170
|
+
color: var(--c-cobalt-400);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.foot {
|
|
174
|
+
display: flex;
|
|
175
|
+
flex-wrap: wrap;
|
|
176
|
+
align-items: center;
|
|
177
|
+
gap: 10px;
|
|
178
|
+
margin-top: 14px;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.start {
|
|
182
|
+
background: var(--c-blue-cobalt);
|
|
183
|
+
color: white;
|
|
184
|
+
border: 1px solid var(--c-blue-cobalt);
|
|
185
|
+
padding: 10px 18px;
|
|
186
|
+
border-radius: var(--radius-md);
|
|
187
|
+
font-family: inherit;
|
|
188
|
+
font-weight: 500;
|
|
189
|
+
font-size: 14px;
|
|
190
|
+
cursor: pointer;
|
|
191
|
+
transition: background 120ms ease;
|
|
192
|
+
}
|
|
193
|
+
.start:hover { background: var(--c-cobalt-700); border-color: var(--c-cobalt-700); }
|
|
194
|
+
.start:focus-visible { outline: 2px solid var(--c-cobalt-900); outline-offset: 2px; }
|
|
195
|
+
|
|
196
|
+
.hint {
|
|
197
|
+
margin: 0;
|
|
198
|
+
flex-basis: 100%;
|
|
199
|
+
font-size: 12px;
|
|
200
|
+
color: var(--c-cobalt-400);
|
|
201
|
+
max-width: 60ch;
|
|
202
|
+
min-height: 1.4em;
|
|
203
|
+
}
|