index.jsx
6.9 kB · jsx · 186 lines
1import { useEffect, useRef, useState } from 'react';2import { ready, ink, rgb } from '../../lib/mrly.js';3import { mount, Page, Row, Btn, Stats, Stat } from '../../lib/app.jsx';4import { useSeeds } from '../../lib/select.jsx';56const m = await ready();7const NUMBER = 3, LEVEL = 4, BASE = 3, WALKERS = 300, TICKS = 12, SCALE = 6;8const swatches = () => [rgb(ink.line), rgb(ink.deep)];9const DIM = m.dimension('127', NUMBER, 2, BASE).toFixed(3);1011const SIDES = [12 { code: '127', color: ink.blue, swatch: 'var(--blue)', tone: 'the fast one' },13 { code: '239', color: ink.orange, swatch: 'var(--orange)', tone: 'the slow one' },14].map((side) => ({15 ...side,16 name: m.name_of(side.code, 2, BASE),17 fill: `${m.fills(side.code, NUMBER, 2, LEVEL, BASE)} of ${m.grid_total(NUMBER, 2, LEVEL)}`,18}));1920function runner(code, color) {21 const off = document.createElement('canvas');22 const tint = rgb(color);23 let race = null, side = 0, types = null, image = null;24 return {25 far: 0,26 reset(seed, canvas) {27 race = new m.Race(code, NUMBER, LEVEL, BASE, WALKERS, seed);28 side = race.side();29 types = race.types();30 canvas.width = canvas.height = side * SCALE;31 off.width = off.height = side;32 image = new ImageData(side, side);33 this.far = 0;34 },35 tick() {36 this.far = race.step(1);37 },38 steps() {39 return race.steps();40 },41 draw(canvas) {42 const n = side, px = image.data, trail = race.trail(), [r, g, b] = tint, [site, hole] = swatches();43 for (let i = 0; i < n * n; i++) {44 const base = types[i] ? site : hole;45 const heat = Math.min(1, trail[i] / 12) * 0.55;46 px[i * 4] = base[0] + (r - base[0]) * heat;47 px[i * 4 + 1] = base[1] + (g - base[1]) * heat;48 px[i * 4 + 2] = base[2] + (b - base[2]) * heat;49 px[i * 4 + 3] = 255;50 }51 for (const p of race.positions()) {52 px.set([r, g, b, 255], p * 4);53 }54 off.getContext('2d').putImageData(image, 0, 0);55 const ctx = canvas.getContext('2d');56 ctx.imageSmoothingEnabled = false;57 ctx.drawImage(off, 0, 0, n * SCALE, n * SCALE);58 const home = race.home();59 ctx.strokeStyle = ink.fg;60 ctx.lineWidth = 2;61 ctx.strokeRect((home % n) * SCALE - 2, Math.floor(home / n) * SCALE - 2, SCALE + 4, SCALE + 4);62 },63 };64}6566function App() {67 const s = useSeeds();68 const [target, setTarget] = useState(20);69 const [finish, setFinish] = useState('20');70 const [running, setRunning] = useState(false);71 const [done, setDone] = useState(false);72 const [label, setLabel] = useState('Start the race');73 const [banner, setBanner] = useState('');74 const [steps, setSteps] = useState('');75 const [far, setFar] = useState([0, 0]);76 const sheets = useRef([]);77 const goal = useRef(target);78 const teams = useRef(null);79 teams.current ??= SIDES.map((side) => runner(side.code, side.color));8081 const paint = () => teams.current.forEach((team, i) => team.draw(sheets.current[i]));8283 const reset = () => {84 const seed = s.get() || 1;85 teams.current[0].reset(seed, sheets.current[0]);86 teams.current[1].reset(seed + 777, sheets.current[1]);87 paint();88 setFar([0, 0]);89 setSteps('');90 setBanner('');91 setDone(false);92 setRunning(false);93 setLabel('Start the race');94 };9596 useEffect(() => {97 reset();98 }, []);99100 useEffect(() => {101 if (!running) return;102 let id = 0;103 const frame = () => {104 let finished = false;105 for (let k = 0; k < TICKS && !finished; k++) {106 for (const team of teams.current) team.tick();107 const [a, b] = teams.current;108 if (a.far >= goal.current || b.far >= goal.current) {109 finished = true;110 const [winner, loser] = a.far >= goal.current ? [0, 1] : [1, 0];111 setBanner(`${SIDES[winner].name} wins at step ${teams.current[winner].steps()}; the other team is at ${teams.current[loser].far.toFixed(1)} cells. Same mass. Different music.`);112 setDone(true);113 setRunning(false);114 setLabel('Race again');115 }116 }117 paint();118 setFar(teams.current.map((team) => team.far));119 setSteps(`${teams.current[0].steps()} steps`);120 if (!finished) id = requestAnimationFrame(frame);121 };122 id = requestAnimationFrame(frame);123 return () => cancelAnimationFrame(id);124 }, [running]);125126 const start = () => {127 setRunning(true);128 setLabel('Pause');129 };130131 const go = () => {132 if (done) {133 s.next();134 reset();135 start();136 return;137 }138 setRunning(!running);139 setLabel(running ? 'Resume' : 'Pause');140 };141142 const randomize = () => {143 s.next();144 reset();145 start();146 };147148 const aim = (value) => {149 goal.current = value;150 setTarget(value);151 reset();152 };153154 const controls = (155 <Row>156 <Btn primary onClick={go}>{label}</Btn>157 <Btn onClick={randomize}>Randomize</Btn>158 <label>finish <input type="number" min={5} max={60} value={finish} onChange={(e) => { setFinish(e.target.value); if (!e.nativeEvent.inputType) aim(+e.target.value); }} onBlur={() => aim(+finish)} onKeyDown={(e) => { if (e.key === 'Enter') aim(+finish); }} /> cells</label>159 </Row>160 );161162 return (163 <Page crumb="race" title="The race: same mass, different music"164 sub={<>Two base-3 rules, each keeping 7 of 9 cells: the same material at every scale and the same fractal dimension <span className="num">{DIM}</span>. Drop random walkers on both and watch the shapes carry them at different speeds. First team to wander an average of <span className="num">{target}</span> cells from home wins.</>}165 controls={controls}166 foot={<>Both patterns are grown to level 4 from their own 3 by 3 rule and the walkers start at the filled cell nearest the centre; each tick every walker takes one blind step, and a step into a hole is a lost turn. The grids, the walkers, the distances and the seed all live in Rust, so a race replays exactly from its seed. Distance grows like a power of time whose exponent the shape sets; this page shows the race itself, not that exponent. The exponent, and the census showing that equal mass does not fix it, are in <a href="/research/walks/">the walk dimension note</a>.</>}>167 <div className="arena">168 {SIDES.map((side, i) => (169 <div className="panel" key={side.code}>170 <h2><i className="swatch" style={{ background: side.swatch }} /><span className="num">{side.name}</span><span>{side.tone}</span></h2>171 <canvas className="sheet" ref={(el) => { sheets.current[i] = el; }} role="img" aria-label={`${side.name}, ${side.tone}`} />172 <div className="meter"><div style={{ background: side.swatch, width: `${Math.min(100, far[i] / target * 100)}%` }} /></div>173 <Stats>174 <Stat label="distance from home">{`${far[i].toFixed(1)} cells`}</Stat>175 <Stat label="filled">{side.fill}</Stat>176 </Stats>177 </div>178 ))}179 </div>180 <p className="shift">{steps}</p>181 <div className="banner">{banner}</div>182 </Page>183 );184}185186mount(<App />);