@import url('https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@400;700&family=Press+Start+2P&display=swap');
$color-lighter-gray: #F3F4F6;
$color-light-gray: #D1D5DB;
$color-gray: #9CA3AF;
$color-dark-gray: #6B7280;
$color-darker-gray: #1F2937;
$color-yellow: #FDE68A;
$color-red: #F87171;
$color-dark-red: #EF4444;
$color-green: #6EE7B7;
$color-blue: #3B82F6;
$cell-size: 2.5rem;
body, html {
min-width: 100vw;
min-height: 100vh;
font-family: 'Roboto Mono', monospace;
}
body {
display: flex;
align-items: center;
justify-content: center;
}
.app {
display: flex;
align-items: center;
&.done {
.table {pointer-events: none}
input.correct {background-color: $color-green}
input.incorrect {background-color: $color-red}
}
}
.table {
display: grid;
grid-template-columns: repeat(var(--cols), $cell-size);
grid-auto-rows: $cell-size;
grid-gap: 1px;
border-radius: 4px;
background-color: gray;
border: 1px solid gray;
overflow: hidden;
box-shadow:
0 0 5px rgba(0,0,0,0.2),
0 0 15px rgba(0,0,0,0.2);
.cell {
display: flex;
align-items: center;
justify-content: center;
background-color: white;
color: $color-gray;
&.header {
background-color: $color-light-gray;
font-weight: bold;
color: black;
&.highlight {
font-size: 1.3rem;
color: $color-blue;
}
}
&.highlight,
&.highlight input {
background-color: $color-yellow;
}
input {
text-align: center;
width: 100%;
height: 100%;
border: none;
background: $color-lighter-gray;
outline: none;
padding: 0;
font-size: 1rem;
font-weight: bold;
box-shadow:
inset 0 0 2px rgba(0,0,0,0.2),
inset 0 0 10px rgba(0,0,0,0.1);
-moz-appearance: textfield;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
}
&:focus {
background-color: $color-yellow;
}
}
}
}
@mixin range-thumb() {
border: 1px solid $color-darker-gray;
height: 20px;
width: 16px;
border-radius: 3px;
background: $color-lighter-gray;
cursor: pointer;
}
@mixin range-track() {
width: 100%;
height: 6px;
cursor: pointer;
background: $color-light-gray;
border-radius: 3px;
}
.controls {
background-color: $color-darker-gray;
color: $color-lighter-gray;
padding: 10px;
margin-left: 10px;
border-radius: 5px;
margin-bottom: 10px;
display: flex;
flex-direction: column;
&.disabled {
opacity: 0.7;
pointer-events: none;
}
label {
display: flex;
align-items: center;
font-size: 1.2rem;
text-transform: uppercase;
span {
margin-right: 5px;
font-size: 0.8rem;
color: $color-gray;
}
}
input {
-webkit-appearance: none;
width: 100%;
background: transparent;
height: 20px;
margin: 5px 0;
}
input::-webkit-slider-thumb {
-webkit-appearance: none;
margin-top: -7px;
@include range-thumb();
}
input::-moz-range-thumb {
@include range-thumb();
}
input::-webkit-slider-runnable-track {
@include range-track();
}
input::-moz-range-track {
@include range-track();
}
}
.panel {
background-color: $color-darker-gray;
color: $color-lighter-gray;
padding: 10px;
margin-left: 10px;
border-radius: 5px;
.timer {
font-size: 1.4rem;
text-align: center;
margin: 5px 0 15px;
font-family: 'Press Start 2P', cursive;
color: $color-red;
text-shadow: 0 0 3px $color-dark-red;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: 10px;
}
.panel-item {
display: flex;
flex-direction: column;
font-size: 1.5rem;
align-items: center;
margin-top: 10px;
transition: all 0.3s ease-out;
span {
font-size: 0.8rem;
color: $color-gray;
margin-bottom: 5px;
text-transform: uppercase;
}
}
button {
background-color: $color-green;
border: none;
border-radius: 4px;
width: 100%;
padding: 10px;
font-size: 1.3rem;
text-align: center;
margin-top: 15px;
cursor: pointer;
}
}
View Compiled
import React, {useState, useEffect, useRef, memo} from 'https://cdn.skypack.dev/react';
import ReactDOM from 'https://cdn.skypack.dev/react-dom';
const random = (min, max) => Math.floor(Math.random() * (max - min) + min);
const getMissing = (size, difficulty) => {
const missing = new Map();
while(missing.size < size * size * difficulty - 1) {
missing.set(`${random(1, size + 1)}.${random(1, size + 1)}`, null);
}
return missing;
};
const Cell = memo(({r, c, focused, children}) => (
<div className={'cell' + (r === 0 || c === 0 ? ' header' : '') + ((focused.r === r && focused.c > c) || (focused.c === c && focused.r > r) ? ' highlight' : '')}>
{children}
</div>
));
const EmptyCell = memo(({r, c, missing, onChange, onFocus}) => {
const value = missing.get(`${r}.${c}`);
return (
<input
type='number'
className={value === r * c ? 'correct' : 'incorrect'}
onFocus={() => onFocus({r, c})}
value={value || ''}
onChange={e => {
const map = new Map(missing);
map.set(`${r}.${c}`, parseInt(e.target.value));
onChange(map);
}}/>
);
});
const NumberCell = memo(({r, c}) => {
if (c === 0 && r === 0) return 'X';
if (r === 0) return c;
if (c === 0) return r;
return r * c;
});
const Board = memo(({size, missing, focused, onFocus, onChange}) => (
<div className='table' style={{'--cols': size + 1}}>
{[...new Array(size + 1)].map((_, r) => [...new Array(size + 1)].map((__, c) => (
<Cell key={`${c}.${r}`} {...{r, c, focused}}>
{c * r !== 0 && missing.has(`${r}.${c}`)
? <EmptyCell {...{r, c, missing, onChange, onFocus}}/>
: <NumberCell {...{r, c}}/>
}
</Cell>
)))}
</div>
));
const Timer = ({run, time, setTime}) => {
const initial = useRef(Date.now());
const interval = useRef();
const format = num => String(num).padStart(2, '0')
useEffect(() => {
if (run) {
initial.current = Date.now();
interval.current = setInterval(() => {
setTime(Math.floor((Date.now() - initial.current)/1000));
}, 100);
} else {
clearInterval(interval.current);
}
return () => clearInterval(interval.current);
}, [run]);
return (
<div className='timer'>
{format(Math.floor(time / 60))}:{format(time % 60)}
</div>
);
}
const PanelItem = ({label, text}) => (
<div className='panel-item'><span>{label}</span>{text}</div>
);
const Panel = ({size, difficulty, missing, done, setDone}) => {
const [time, setTime] = useState(0);
const [score, setScore] = useState(null);
const onClick = e => {
setDone(d => !d);
if (!done) {
let correct = 0;
missing.forEach((value, key) => {
const s = key.split('.');
correct += parseInt(s[0]) * parseInt(s[1]) === value;
});
const coefficient = correct * size * (1 + difficulty);
const score = Math.ceil(coefficient + Math.pow(coefficient, 2) / Math.max(1, time));
setScore(score);
party.sparkles(e.target, {count: 50});
} else {
setScore(null);
}
};
return (
<div className='panel'>
<Timer run={!done} {...{time, setTime}}/>
<div className='grid'>
<PanelItem label='Remaining' text={Array.from(missing.values()).filter(v => v === null).length}/>
<PanelItem label='Score' text={score !== null ? score : '----'}/>
</div>
<button onClick={onClick}>{done ? 'Start' : "I'm Done!"}</button>
</div>
);
};
const Controls = ({done, difficulty, setDifficulty, size, setSize}) => {
const [v, s] = useState(size);
const onSizeChange = e => setSize(parseInt(e.target.value));
return (
<div className={'controls' + (done ? '' : ' disabled')}>
<label><span>Difficulty:</span> {difficulty * 100}%</label>
<input type='range' value={difficulty * 100} onChange={e => setDifficulty(parseInt(e.target.value) / 100)} min={10} max={90} step={10}/>
<label><span>Board Size:</span> {v}X{v}</label>
<input type='range' value={v} onChange={e => s(e.target.value)} onMouseUp={onSizeChange} onTouchEnd={onSizeChange} min={3} max={30} step={1}/>
</div>
);
};
const App = () => {
const [done, setDone] = useState(true);
const [focused, setFocused] = useState({});
const [missing, setMissing] = useState(new Map());
const [difficulty, setDifficulty] = useState(0.3);
const [size, setSize] = useState(8);
useEffect(() => {
setFocused({});
if (!done) {
setMissing(getMissing(size, difficulty));
}
}, [done]);
return (
<div className={'app' + (done ? ' done' : '')}>
<Board {...{size, missing, focused, onFocus: setFocused, onChange: setMissing}}/>
<div>
<Controls {...{done, difficulty, setDifficulty, size, setSize}}/>
<Panel {...{size, difficulty, missing, done, setDone}}/>
</div>
</div>
);
}
ReactDOM.render(
<App/>,
document.body
);
View Compiled
This Pen doesn't use any external CSS resources.