File size: 4,226 Bytes
17641e3
 
 
 
 
 
 
 
 
 
 
6d7e8fa
 
450f6c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17641e3
 
 
 
 
 
 
 
 
 
 
 
450f6c1
6d7e8fa
 
 
 
 
 
 
17641e3
 
6d7e8fa
17641e3
6d7e8fa
693efa9
 
 
 
 
 
 
 
 
302a725
17641e3
 
 
 
 
 
 
302a725
17641e3
 
 
302a725
17641e3
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
/* ============================================================
   Fixed-timestep game loop.
   Simulation runs at a constant rate (default 20 Hz) regardless of
   display refresh rate, via an accumulator — the render callback runs
   once per animation frame and gets the leftover fraction for
   interpolation if it wants it. This is the seam that makes the sim
   deterministic-ish and decoupled from how fast the browser paints.
   ============================================================ */

"use strict";

const MAX_SUBSTEPS = 5;   // most catch-up sim ticks to run in one animation frame

// A speed MULTIPLIER on how much sim time one real second buys (docs/competitions-and-elo.md
// Phase 5: 2x/4x/8x for a spectated match). Either a plain number or a getter, so a caller can
// let the player change speed mid-match without rebuilding the loop.
//
// WHY IT SCALES THE ACCUMULATOR AND NOT `hz`. The obvious implementation — 4x means dtFixed/4 —
// would change THE FIXED TIMESTEP, and the fixed timestep is the thing that makes this sim
// replayable: same seed ⇒ same game only holds because every tick is the same dt in the same
// order. Scaling the time that feeds the accumulator instead leaves `update(dtFixed)` byte-for-
// byte what it always was and changes only how MANY of those identical steps a real second runs.
// It also lands the overload in exactly the place the existing design already handles well: past
// MAX_SUBSTEPS the loop degrades to slow motion (dropping sim time) rather than spiralling into
// an ever-deepening backlog, so 8x on a machine that can't feed it just runs slower than the
// label promises instead of freezing the tab.
// Anything that isn't a finite positive number reads as 1x — an invalid speed must never be able
// to stall the loop (0) or run it backwards.
function speedMultiplier(speed) {
  const v = typeof speed === "function" ? speed() : speed;
  return (typeof v === "number" && Number.isFinite(v) && v > 0) ? v : 1;
}

export function createLoop({ update, render, hz = 20, speed = 1, now = () => performance.now() }) {   // deterministic-exempt: wall clock drives the render loop, not the sim
  const dtFixed = 1 / hz;
  let acc = 0;
  let last = null;
  let running = false;
  let rafId = null;

  function frame(t) {
    if (!running) return;
    if (last === null) last = t;
    let delta = (t - last) / 1000;
    last = t;
    if (delta > 0.25) delta = 0.25;   // clamp so a backgrounded tab doesn't spiral on return
    acc += delta * speedMultiplier(speed);   // …then buy that much MORE sim time per real second (see above)
    // Cap catch-up substeps per frame. If a single update ever runs longer than
    // the fixed step (plausible on a Gigantic map once armies are huge), the
    // accumulator would otherwise grow every frame and the sim spirals into an
    // ever-deepening backlog. Capping lets it degrade to slow-motion — dropping
    // sim time — instead, which stays responsive and recovers.
    let steps = 0;
    while (acc >= dtFixed && steps < MAX_SUBSTEPS) {
      update(dtFixed);
      acc -= dtFixed;
      steps++;
    }
    if (acc > dtFixed) acc = 0;   // over the cap: drop the backlog rather than carry it forward
    // A throwing render (e.g. a bad draw call on one odd entity) must not brick the whole
    // loop: without this, the throw unwinds past the requestAnimationFrame call below and
    // the session is permanently frozen. update() is deliberately left unguarded — a
    // throwing sim update is a correctness problem this catch isn't meant to paper over.
    try {
      render(acc / dtFixed);
    } catch (err) {
      console.error("render() threw; skipping this frame", err);
    }
    rafId = requestAnimationFrame(frame);   // browser-exempt: the render loop IS the browser seam; drives no sim state
  }

  return {
    start() {
      if (running) return;
      running = true;
      last = null;
      rafId = requestAnimationFrame(frame);   // browser-exempt: render-loop seam only
    },
    stop() {
      running = false;
      if (rafId !== null) cancelAnimationFrame(rafId);   // browser-exempt: render-loop seam only
    },
    get running() { return running; },
  };
}