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 />);