Gauntlet #340: spec, package skeleton, scoring, six trials
Add the DESIGN.md (spec copy + implementation deviations), the trials package (Trial base + ordered registry + six stress trials: entity swarm, animation storm, grid titan, pathfinder rush, ui avalanche, sightline siege), and the RampController/scoring logic. All pure Python under tests/benchmarks/gauntlet/. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
c6b21b9a6b
commit
859730f02d
10 changed files with 1039 additions and 0 deletions
235
tests/benchmarks/gauntlet/DESIGN.md
Normal file
235
tests/benchmarks/gauntlet/DESIGN.md
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
# Implementation deviations from the original spec
|
||||
|
||||
*(Recorded by the implementer per the spec's instruction below. The original design is
|
||||
preserved verbatim after this section.)*
|
||||
|
||||
1. **`get_metrics()["frame_time"]` is in MILLISECONDS, not seconds.** The spec's "Engine
|
||||
API notes" says frame_time is in seconds; runtime probing shows raw values around 16.7
|
||||
at 60 fps -- i.e. milliseconds. Scoring and the HUD consume frame_time directly as ms
|
||||
(no `* 1000`).
|
||||
2. **vsync / framerate cap must be disabled to measure.** With default windowed vsync the
|
||||
frame_time floors at ~16.7 ms (the 60 Hz budget) regardless of load, so the ramp could
|
||||
never observe a genuine failure. `run_gauntlet.py` and `gauntlet_main.py` set
|
||||
`mcrfpy.window.vsync = False` and `mcrfpy.window.framerate_limit = 0` at startup.
|
||||
3. **HUD FPS is derived as `1000 / frame_time`.** The metrics `fps` field is a cumulative
|
||||
running average over total runtime (it starts in the thousands and converges slowly),
|
||||
not an instantaneous rate, so it is unusable as a live readout. Instantaneous FPS is
|
||||
computed from the smoothed frame time instead.
|
||||
4. **`draw_calls`, `ui_elements`, `visible_elements`, and entity render counters read 0**
|
||||
in this build even while a scene is visibly rendering (the engine's own metrics test
|
||||
notes these "may be 0 if scene hasn't rendered yet"). They are displayed honestly
|
||||
(0 when the engine reports 0) rather than faked; `metrics_at_peak` still stores the full
|
||||
dict. frame_time is the sole scoring signal.
|
||||
5. **`tick(dt_ms)` runs on a dedicated 100 ms simulation Timer**, separate from the 16 ms
|
||||
scoring sampler and the 100 ms HUD refresh. The spec described tick as "optional
|
||||
per-sample work"; each trial's periodic load (grid.step, path queries, color-region
|
||||
rewrites, z-shuffles, FOV recompute) runs on this sim cadence, matching each trial's own
|
||||
description ("driven on a 100 ms Timer", "every tick", etc.).
|
||||
6. **Headless full runs cannot score.** In headless mode frame_time is 0.0 (no real
|
||||
rendering) and timers fire one-per-`step()`, so `run_gauntlet.py` only yields a real
|
||||
baseline windowed (or under xvfb). The headless unit test exercises
|
||||
setup / set_load / teardown directly, not the ramp.
|
||||
7. Animations use the current `obj.animate(...)` method (the older
|
||||
`mcrfpy.Animation(...).start()` seen in some demos is not used).
|
||||
|
||||
---
|
||||
|
||||
# THE GAUNTLET — McRogueFace Interactive Stress Benchmark
|
||||
|
||||
**Design spec (Fable, 2026-07-02).** Implementer: follow this spec; where the engine
|
||||
API disagrees with an assumption here, prefer the engine and note the deviation at the
|
||||
top of the committed copy of this file.
|
||||
|
||||
## Concept
|
||||
|
||||
An on-screen benchmark the user *watches*: six themed "trials", each stress-testing one
|
||||
engine subsystem with steadily ramping load until the frame budget breaks. The maximum
|
||||
sustained load is that trial's score. Scores are written as a JSON baseline so future
|
||||
engine changes can be diffed (regression = red, improvement = green) right on screen.
|
||||
|
||||
Two ways to run:
|
||||
- **Interactive** (`gauntlet_main.py`): menu, watch any trial, drive load manually or auto-ramp.
|
||||
- **Baseline run** (`run_gauntlet.py`): runs all six trials back-to-back with auto-ramp,
|
||||
shows the results screen, writes `baseline/gauntlet/latest.json` (and promotes to
|
||||
`baseline.json` if none exists).
|
||||
|
||||
## File layout
|
||||
|
||||
```
|
||||
tests/benchmarks/gauntlet/
|
||||
├── DESIGN.md # this file (committed with the code)
|
||||
├── gauntlet_main.py # interactive entry point (menu scene + trial scenes + results)
|
||||
├── run_gauntlet.py # unattended full run -> results screen -> baseline JSON
|
||||
├── hud.py # shared HUD overlay (see Visual Identity)
|
||||
├── scoring.py # RampController: hold-window sampling, p50/p95, pass/fail, grades
|
||||
├── baseline_io.py # read/write/compare baseline JSON (schema below)
|
||||
└── trials/
|
||||
├── __init__.py # Trial base class + TRIALS registry (ordered)
|
||||
├── entity_swarm.py
|
||||
├── animation_storm.py
|
||||
├── grid_titan.py
|
||||
├── pathfinder_rush.py
|
||||
├── ui_avalanche.py
|
||||
└── sightline_siege.py
|
||||
```
|
||||
|
||||
**All source files ASCII-only** (the `--exec` loader rejects non-ASCII). No unicode
|
||||
glyphs anywhere, including Caption text: use `^` / `v` for deltas, `*` for accents.
|
||||
|
||||
## Visual identity
|
||||
|
||||
Dark, instrument-panel aesthetic. The benchmark should feel like a cockpit gauge
|
||||
cluster bolted over a chaotic arena.
|
||||
|
||||
Palette (mcrfpy.Color):
|
||||
- Background `#0d0f14` (13,15,20); HUD panel fill `#161a22` (22,26,34) with 1px outline `#2a3140` (42,49,64)
|
||||
- Primary text `#e8eaf0` (232,234,240); dim text `#8a93a6` (138,147,166)
|
||||
- Frame-budget colors: OK (< 16.7 ms) mint `#38d996` (56,217,150); warn (16.7-33 ms)
|
||||
amber `#f5b83d` (245,184,61); fail (> 33 ms) red `#e5484d` (229,72,77)
|
||||
|
||||
Trial accent colors (used for the trial banner, its menu row, its arena flavor, and
|
||||
its row on the results screen):
|
||||
|
||||
| # | Trial | Subsystem | Accent |
|
||||
|---|------------------|---------------------------|---------------------------|
|
||||
| 1 | ENTITY SWARM | entity step/render | amber (245,165,36) |
|
||||
| 2 | ANIMATION STORM | animation manager | magenta (229,85,157) |
|
||||
| 3 | GRID TITAN | grid render + layer writes| cyan (53,193,214) |
|
||||
| 4 | PATHFINDER RUSH | Dijkstra/A* queries | green (76,194,110) |
|
||||
| 5 | UI AVALANCHE | UI hierarchy + draw calls | violet (154,110,245) |
|
||||
| 6 | SIGHTLINE SIEGE | FOV + perspective | crimson (229,72,77) |
|
||||
|
||||
### HUD (hud.py) — identical overlay on every trial scene
|
||||
|
||||
Top strip, full width, panel-filled:
|
||||
- Left: trial name in its accent color, plus one-line description in dim text.
|
||||
- Center: **frame-time sparkline** — 60 thin Frame bars (4 px wide, 2 px gap),
|
||||
bar height = frame_time clamped to [0, 50] ms mapped to [2, 48] px, colored by the
|
||||
budget colors above. A 1px horizontal hairline marks the 16.7 ms budget.
|
||||
- Right: big FPS readout (large Caption, budget-colored), under it
|
||||
`frame p95: NN.N ms`, `draw calls: N`, and the load line `LOAD: <value> <unit>`
|
||||
with ramp state tag `[RAMP k]` / `[HOLD]` / `[MANUAL]`.
|
||||
|
||||
Bottom strip: key legend in dim text
|
||||
`[SPACE] pause [LEFT/RIGHT] trial [-/+] load [A] auto-ramp [R] run gauntlet [S] shot [ESC] menu/quit`
|
||||
|
||||
HUD refreshes on a 100 ms Timer (10 Hz), NOT per frame; metric samples for scoring are
|
||||
taken on their own 16 ms Timer. HUD cost is constant across trials — that is fine, it
|
||||
is part of the harness and identical everywhere.
|
||||
|
||||
### Menu scene
|
||||
|
||||
Title `THE GAUNTLET` centered large, subtitle `McRogueFace stress benchmark` in dim
|
||||
text; engine version + commit short-hash bottom-left (read version from
|
||||
`mcrfpy.__version__` if present, else omit; commit passed in by run script or read
|
||||
lazily via `subprocess` is NOT allowed inside the engine — obtain it in baseline_io
|
||||
with `git rev-parse --short HEAD` guarded by try/except when writing JSON only).
|
||||
Six menu rows, one per trial: number, name in accent, unit, and — if a baseline
|
||||
exists — its baseline max_load in dim text. A slow color-cycle animation on the title
|
||||
(animate fill_color through the six accents, 12 s loop) gives the screen life without
|
||||
costing measurable frame time.
|
||||
|
||||
### Results scene
|
||||
|
||||
Table, one row per trial: name (accent), `max_load unit`, `p95 ms at peak`, and when a
|
||||
baseline exists: delta column `^ +12%` (mint) / `v -8%` (red) / `= same` (dim).
|
||||
Footer: **GAUNTLET SCORE** = geometric mean of per-trial `max_load / baseline_max_load`
|
||||
ratios, displayed as `xNN.N%` vs baseline (=100% at parity); when no baseline exists show
|
||||
`FIRST RUN -- baseline recorded` instead. Below it, letter grade per trial vs baseline:
|
||||
S >= 150%, A >= 100%, B >= 80%, C >= 60%, D below; overall grade from the geomean.
|
||||
|
||||
## Trials
|
||||
|
||||
Common contract (`trials/__init__.py`):
|
||||
|
||||
```python
|
||||
class Trial:
|
||||
name = "ENTITY SWARM"; unit = "entities"; accent = (245,165,36)
|
||||
description = "one line"
|
||||
base_load = 50 # starting load
|
||||
growth = 1.6 # geometric ramp factor: load_k = round(base * growth**k)
|
||||
def setup(self, scene, ui): ... # build arena, return nothing
|
||||
def set_load(self, level_value): ... # create/destroy stress objects to match
|
||||
def tick(self, dt_ms): ... # optional per-sample work (default no-op)
|
||||
def teardown(self): ...
|
||||
```
|
||||
|
||||
1. **ENTITY SWARM** — one Grid (~40x25 visible), N entities with SEEK behavior toward a
|
||||
wandering target using a Dijkstra map, `grid.step()` driven on a 100 ms Timer;
|
||||
entities render with sprites. Load = entity count.
|
||||
2. **ANIMATION STORM** — N small Frames scattered on screen, each running two concurrent
|
||||
animations (position with random easing, fill_color pulse), re-launched from the
|
||||
animation-complete callback so the storm is self-sustaining. Load = live animations (2N).
|
||||
3. **GRID TITAN** — square Grid of side S with a TileLayer + ColorLayer; every tick,
|
||||
rewrite a 32x32 ColorLayer region (rolling window) and animate the camera center in a
|
||||
slow orbit so chunks keep invalidating. Load = S (grid side; cells = S*S). growth 1.4.
|
||||
4. **PATHFINDER RUSH** — static maze grid (BSP or simple rooms); each tick issue Q
|
||||
`grid.find_path` / Dijkstra queries between random walkable pairs. Load = queries/tick.
|
||||
5. **UI AVALANCHE** — nested Frame trees (depth 5) each with Captions and Sprites, plus a
|
||||
z-order shuffle of the top-level frames every tick to defeat caching. Load = total
|
||||
UI elements.
|
||||
6. **SIGHTLINE SIEGE** — Grid with scattered walls; N entities each with an active FOV
|
||||
(`compute_fov`, radius 10) recomputed on a 100 ms step as they random-walk, exercising
|
||||
perspective writeback (#316). Load = FOV entities.
|
||||
|
||||
Trials must create everything under their own Scene and fully dispose in `teardown()`
|
||||
(remove entities, stop timers, drop references) so trials do not contaminate each other.
|
||||
|
||||
## Ramp + scoring (scoring.py)
|
||||
|
||||
- Auto-ramp: set load, **settle 1.0 s** (discard samples), then **hold 2.0 s** collecting
|
||||
`get_metrics()["frame_time"]` samples on the 16 ms sampler Timer.
|
||||
- Pass: p95 <= 16.67 ms -> next ramp step. Fail: stop; trial score = last PASSING load
|
||||
and its held p50/p95. Also bail immediately if any sample > 100 ms (hard cap).
|
||||
- Record per trial: `unit, max_load, p50_ms, p95_ms, samples, metrics_at_peak`
|
||||
(metrics_at_peak = the full get_metrics() dict from the last passing window).
|
||||
|
||||
## Baseline JSON (baseline_io.py)
|
||||
|
||||
Path: `tests/benchmarks/baseline/gauntlet/`. Schema:
|
||||
|
||||
```json
|
||||
{ "schema": 1, "version": "0.2.8", "commit": "abc1234", "date": "2026-07-02",
|
||||
"platform": "<platform.platform()>", "budget_ms": 16.67,
|
||||
"trials": { "entity_swarm": { "unit": "entities", "max_load": 3200,
|
||||
"p50_ms": 9.1, "p95_ms": 15.8, "samples": 122,
|
||||
"metrics_at_peak": { "...": "full get_metrics dict" } } },
|
||||
"gauntlet_score_vs_baseline": 1.0 }
|
||||
```
|
||||
|
||||
Every full run writes `latest.json`; if `baseline.json` is absent, copy it there.
|
||||
`baseline_io.compare(latest, baseline)` returns per-trial ratios + geomean for the
|
||||
results screen. Never overwrite `baseline.json` automatically once it exists.
|
||||
|
||||
## Engine API notes for the implementer (verified against this codebase)
|
||||
|
||||
- `mcrfpy.get_metrics()` -> dict with `frame_time` (s), `avg_frame_time`, `fps`,
|
||||
`draw_calls`, `ui_elements`, `visible_elements`, `current_frame`, `runtime`,
|
||||
`grid_render_time`, `entity_render_time`, `fov_overlay_time`, `python_time`,
|
||||
`animation_time`, `grid_cells_rendered`, `entities_rendered`, `total_entities`.
|
||||
- Scenes: `s = mcrfpy.Scene("name")`; children via `s.children`; activate with
|
||||
`mcrfpy.current_scene = s` or `s.activate()`; keyboard `s.on_key = fn(key, state)`
|
||||
with `mcrfpy.Key` / `mcrfpy.InputState` enums.
|
||||
- Timers: `mcrfpy.Timer("name", cb, interval_ms)`, callback `(timer, runtime_ms)`;
|
||||
methods stop/pause/resume/restart. In headless, timers fire only via `mcrfpy.step(dt)`,
|
||||
ONE event per step.
|
||||
- Animation: `obj.animate("x", 500.0, 2.0, mcrfpy.Easing.EASE_IN_OUT, callback=fn)`;
|
||||
callback receives `(target, property, final_value)`.
|
||||
- Captions: keyword args (`Caption(text=..., pos=...)`); Grid center is in pixels,
|
||||
`grid.center_camera((tx, ty))` for tile coords; `grid.at(x,y)` GridPoint has
|
||||
walkable/transparent; layers: construct `ColorLayer(name=..., z_index=...)` then
|
||||
`grid.add_layer(layer)`; `entity.set_behavior(mcrfpy.Behavior.SEEK, pathfinder=...)`.
|
||||
- Check `tests/demo/screens/*.py` and `tests/benchmarks/*.py` for working idioms before
|
||||
writing each trial; `stubs/mcrfpy.pyi` is the API contract.
|
||||
|
||||
## Verification expected from the implementer
|
||||
|
||||
1. Each trial runs standalone headless (`--headless --exec`) driving `mcrfpy.step()`:
|
||||
setup, set_load at two levels, teardown — asserting object counts and no errors.
|
||||
Commit as `tests/unit/gauntlet_trials_test.py` (fast, suite-friendly).
|
||||
2. Screenshot of the menu, one mid-trial scene, and the results scene via
|
||||
`automation.screenshot()` under headless timers; eyeball-check layout matches this
|
||||
spec (attach paths in your report).
|
||||
3. A real baseline attempt: run `run_gauntlet.py` windowed if a display is available,
|
||||
else under `xvfb-run` if installed. If neither works, say so in your report and
|
||||
leave baseline capture to the operator — do NOT ship a fake baseline.json.
|
||||
6
tests/benchmarks/gauntlet/__init__.py
Normal file
6
tests/benchmarks/gauntlet/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
"""The Gauntlet -- McRogueFace interactive stress benchmark (Gitea #340).
|
||||
|
||||
Run interactively: ./mcrogueface tests/benchmarks/gauntlet/gauntlet_main.py
|
||||
Capture a baseline: ./mcrogueface tests/benchmarks/gauntlet/run_gauntlet.py
|
||||
See DESIGN.md for the full specification and implementation deviations.
|
||||
"""
|
||||
177
tests/benchmarks/gauntlet/scoring.py
Normal file
177
tests/benchmarks/gauntlet/scoring.py
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
"""Ramp control and scoring for The Gauntlet.
|
||||
|
||||
The RampController is a small state machine driven by a scoring "sample" call
|
||||
(nominally a 16 ms Timer). It sets a trial's load, discards a settle window,
|
||||
collects frame-time samples over a hold window, and decides pass/fail:
|
||||
|
||||
pass : p95 <= budget_ms -> record as new peak, ramp load up one step
|
||||
fail : p95 > budget_ms -> stop; score = last passing load
|
||||
bail : any sample > hard_cap_ms -> stop immediately (fail)
|
||||
|
||||
frame_time is read from get_metrics()["frame_time"] and is already in
|
||||
milliseconds in this engine build (see DESIGN.md deviation 1).
|
||||
"""
|
||||
|
||||
BUDGET_MS = 16.67
|
||||
HARD_CAP_MS = 100.0
|
||||
SETTLE_MS = 1000
|
||||
HOLD_MS = 2000
|
||||
MAX_STEPS = 40 # safety ceiling so a trial that never breaks budget still terminates
|
||||
|
||||
|
||||
def percentile(values, q):
|
||||
"""Nearest-rank percentile of an unsorted list. q in [0, 1]."""
|
||||
if not values:
|
||||
return 0.0
|
||||
s = sorted(values)
|
||||
if len(s) == 1:
|
||||
return s[0]
|
||||
idx = int(round(q * (len(s) - 1)))
|
||||
idx = max(0, min(len(s) - 1, idx))
|
||||
return s[idx]
|
||||
|
||||
|
||||
def load_at_step(base_load, growth, k):
|
||||
"""Geometric ramp: load_k = round(base * growth**k)."""
|
||||
return int(round(base_load * (growth ** k)))
|
||||
|
||||
|
||||
def grade_for_ratio(ratio):
|
||||
"""Letter grade vs baseline ratio (max_load / baseline_max_load)."""
|
||||
if ratio >= 1.50:
|
||||
return "S"
|
||||
if ratio >= 1.00:
|
||||
return "A"
|
||||
if ratio >= 0.80:
|
||||
return "B"
|
||||
if ratio >= 0.60:
|
||||
return "C"
|
||||
return "D"
|
||||
|
||||
|
||||
class RampController:
|
||||
"""Drives one trial through its auto-ramp and produces a result dict.
|
||||
|
||||
Usage: create, call start(), then call sample(now_ms, metrics) repeatedly
|
||||
from the scoring timer. When .done is True, .result holds the final dict.
|
||||
"""
|
||||
|
||||
def __init__(self, trial, metrics_provider,
|
||||
budget_ms=BUDGET_MS, hard_cap_ms=HARD_CAP_MS,
|
||||
settle_ms=SETTLE_MS, hold_ms=HOLD_MS, max_steps=MAX_STEPS,
|
||||
on_finish=None):
|
||||
self.trial = trial
|
||||
self.metrics_provider = metrics_provider # callable -> full get_metrics() dict
|
||||
self.budget_ms = budget_ms
|
||||
self.hard_cap_ms = hard_cap_ms
|
||||
self.settle_ms = settle_ms
|
||||
self.hold_ms = hold_ms
|
||||
self.max_steps = max_steps
|
||||
self.on_finish = on_finish
|
||||
|
||||
self.k = 0
|
||||
self.load = trial.base_load
|
||||
self.phase = "idle" # idle | settle | hold | done
|
||||
self.phase_start = None
|
||||
self.samples = []
|
||||
self.last_pass = None # dict of last passing window
|
||||
self.done = False
|
||||
self.result = None
|
||||
|
||||
# -- public API -------------------------------------------------------
|
||||
def start(self):
|
||||
self.k = 0
|
||||
self.load = int(self.trial.base_load)
|
||||
self.trial.set_load(self.load)
|
||||
self.phase = "settle"
|
||||
self.phase_start = None
|
||||
self.samples = []
|
||||
self.last_pass = None
|
||||
self.done = False
|
||||
self.result = None
|
||||
|
||||
@property
|
||||
def ramp_tag(self):
|
||||
if self.done:
|
||||
return "[DONE]"
|
||||
if self.phase == "hold":
|
||||
return "[HOLD]"
|
||||
return "[RAMP %d]" % self.k
|
||||
|
||||
def sample(self, now_ms, metrics=None):
|
||||
if self.done or self.phase == "idle":
|
||||
return
|
||||
if metrics is None:
|
||||
metrics = self.metrics_provider()
|
||||
ft = float(metrics.get("frame_time", 0.0))
|
||||
|
||||
if self.phase_start is None:
|
||||
self.phase_start = now_ms
|
||||
elapsed = now_ms - self.phase_start
|
||||
|
||||
if self.phase == "settle":
|
||||
if elapsed >= self.settle_ms:
|
||||
self.phase = "hold"
|
||||
self.phase_start = now_ms
|
||||
self.samples = []
|
||||
return
|
||||
|
||||
# hold phase
|
||||
self.samples.append(ft)
|
||||
if ft > self.hard_cap_ms:
|
||||
self._evaluate(metrics, forced_fail=True)
|
||||
return
|
||||
if elapsed >= self.hold_ms:
|
||||
self._evaluate(metrics, forced_fail=False)
|
||||
|
||||
# -- internal ---------------------------------------------------------
|
||||
def _evaluate(self, metrics, forced_fail):
|
||||
p50 = percentile(self.samples, 0.50)
|
||||
p95 = percentile(self.samples, 0.95)
|
||||
passed = (not forced_fail) and (p95 <= self.budget_ms)
|
||||
|
||||
if passed:
|
||||
self.last_pass = {
|
||||
"load": self.load,
|
||||
"p50_ms": round(p50, 3),
|
||||
"p95_ms": round(p95, 3),
|
||||
"samples": len(self.samples),
|
||||
"metrics_at_peak": dict(metrics),
|
||||
}
|
||||
if self.k + 1 > self.max_steps:
|
||||
self._finish()
|
||||
return
|
||||
self.k += 1
|
||||
self.load = load_at_step(self.trial.base_load, self.trial.growth, self.k)
|
||||
self.trial.set_load(self.load)
|
||||
self.phase = "settle"
|
||||
self.phase_start = None
|
||||
self.samples = []
|
||||
else:
|
||||
self._finish()
|
||||
|
||||
def _finish(self):
|
||||
self.phase = "done"
|
||||
self.done = True
|
||||
if self.last_pass is not None:
|
||||
lp = self.last_pass
|
||||
self.result = {
|
||||
"unit": self.trial.unit,
|
||||
"max_load": lp["load"],
|
||||
"p50_ms": lp["p50_ms"],
|
||||
"p95_ms": lp["p95_ms"],
|
||||
"samples": lp["samples"],
|
||||
"metrics_at_peak": lp["metrics_at_peak"],
|
||||
}
|
||||
else:
|
||||
# First load already failed -- record a zero score honestly.
|
||||
self.result = {
|
||||
"unit": self.trial.unit,
|
||||
"max_load": 0,
|
||||
"p50_ms": round(percentile(self.samples, 0.50), 3),
|
||||
"p95_ms": round(percentile(self.samples, 0.95), 3),
|
||||
"samples": len(self.samples),
|
||||
"metrics_at_peak": self.metrics_provider(),
|
||||
}
|
||||
if self.on_finish:
|
||||
self.on_finish(self)
|
||||
85
tests/benchmarks/gauntlet/trials/__init__.py
Normal file
85
tests/benchmarks/gauntlet/trials/__init__.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"""Trial base class and ordered TRIALS registry for The Gauntlet.
|
||||
|
||||
Each trial stress-tests one engine subsystem. The harness owns all Timers; a
|
||||
trial only implements setup / set_load / tick / teardown and holds no timers of
|
||||
its own, so teardown fully disposes its scene contents and trials never
|
||||
contaminate each other.
|
||||
"""
|
||||
|
||||
|
||||
class Trial:
|
||||
# -- identity (override in subclasses) --------------------------------
|
||||
key = "trial"
|
||||
name = "TRIAL"
|
||||
unit = "units"
|
||||
accent = (200, 200, 200)
|
||||
description = "one line"
|
||||
|
||||
# -- ramp parameters --------------------------------------------------
|
||||
base_load = 50
|
||||
growth = 1.6
|
||||
|
||||
# -- lifecycle --------------------------------------------------------
|
||||
def __init__(self):
|
||||
self.scene = None
|
||||
self.ui = None
|
||||
self.load = 0
|
||||
|
||||
def setup(self, scene, ui):
|
||||
"""Build the arena under `scene`. `ui` is scene.children."""
|
||||
self.scene = scene
|
||||
self.ui = ui
|
||||
|
||||
def set_load(self, level_value):
|
||||
"""Create/destroy stress objects so the live load matches level_value."""
|
||||
raise NotImplementedError
|
||||
|
||||
def tick(self, dt_ms):
|
||||
"""Periodic simulation work (100 ms sim cadence). Default: no-op."""
|
||||
pass
|
||||
|
||||
def teardown(self):
|
||||
"""Remove everything this trial created."""
|
||||
if self.scene is not None:
|
||||
children = self.scene.children
|
||||
# Clear any grids' entity collections first.
|
||||
for child in list(children):
|
||||
ents = getattr(child, "entities", None)
|
||||
if ents is not None:
|
||||
try:
|
||||
while len(ents):
|
||||
ents.remove(ents[len(ents) - 1])
|
||||
except Exception:
|
||||
pass
|
||||
while len(children):
|
||||
children.remove(children[len(children) - 1])
|
||||
self.scene = None
|
||||
self.ui = None
|
||||
self.load = 0
|
||||
|
||||
# -- helpers ----------------------------------------------------------
|
||||
def _count_children(self):
|
||||
return len(self.ui) if self.ui is not None else 0
|
||||
|
||||
|
||||
# Populated at import time from the individual trial modules.
|
||||
from .entity_swarm import EntitySwarm
|
||||
from .animation_storm import AnimationStorm
|
||||
from .grid_titan import GridTitan
|
||||
from .pathfinder_rush import PathfinderRush
|
||||
from .ui_avalanche import UIAvalanche
|
||||
from .sightline_siege import SightlineSiege
|
||||
|
||||
TRIALS = [
|
||||
EntitySwarm,
|
||||
AnimationStorm,
|
||||
GridTitan,
|
||||
PathfinderRush,
|
||||
UIAvalanche,
|
||||
SightlineSiege,
|
||||
]
|
||||
|
||||
|
||||
def make_trials():
|
||||
"""Return a fresh instance of every trial, in order."""
|
||||
return [cls() for cls in TRIALS]
|
||||
88
tests/benchmarks/gauntlet/trials/animation_storm.py
Normal file
88
tests/benchmarks/gauntlet/trials/animation_storm.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"""Trial 2: ANIMATION STORM -- animation manager stress.
|
||||
|
||||
N small Frames, each running two concurrent, self-relaunching animations
|
||||
(position with random easing + a fill_color pulse). Load = live animations = 2N.
|
||||
"""
|
||||
import random
|
||||
|
||||
import mcrfpy
|
||||
|
||||
from . import Trial
|
||||
|
||||
ARENA_X, ARENA_Y = 20, 110
|
||||
ARENA_W, ARENA_H = 984, 620
|
||||
BOX = 16
|
||||
|
||||
|
||||
class AnimationStorm(Trial):
|
||||
key = "animation_storm"
|
||||
name = "ANIMATION STORM"
|
||||
unit = "animations"
|
||||
accent = (229, 85, 157)
|
||||
description = "N frames x 2 self-sustaining animations"
|
||||
base_load = 60
|
||||
growth = 1.6
|
||||
|
||||
def setup(self, scene, ui):
|
||||
super().setup(scene, ui)
|
||||
self.rng = random.Random(0xA817)
|
||||
self.frames = []
|
||||
self.live = set()
|
||||
self.easings = [
|
||||
mcrfpy.Easing.LINEAR, mcrfpy.Easing.EASE_IN_OUT_SINE,
|
||||
mcrfpy.Easing.EASE_OUT_CUBIC, mcrfpy.Easing.EASE_IN_OUT_BACK,
|
||||
mcrfpy.Easing.EASE_OUT_BOUNCE,
|
||||
]
|
||||
|
||||
def _new_frame(self):
|
||||
x = self.rng.uniform(ARENA_X, ARENA_X + ARENA_W - BOX)
|
||||
y = self.rng.uniform(ARENA_Y, ARENA_Y + ARENA_H - BOX)
|
||||
fr = mcrfpy.Frame(pos=(x, y), size=(BOX, BOX))
|
||||
fr.fill_color = mcrfpy.Color(self.rng.randint(80, 255),
|
||||
self.rng.randint(40, 200),
|
||||
self.rng.randint(120, 255))
|
||||
self.ui.append(fr)
|
||||
self.frames.append(fr)
|
||||
self.live.add(fr)
|
||||
self._launch_pos(fr)
|
||||
self._launch_color(fr)
|
||||
|
||||
def _launch_pos(self, fr):
|
||||
if fr not in self.live:
|
||||
return
|
||||
tx = self.rng.uniform(ARENA_X, ARENA_X + ARENA_W - BOX)
|
||||
dur = self.rng.uniform(0.4, 1.2)
|
||||
fr.animate("x", tx, dur, self.rng.choice(self.easings),
|
||||
callback=self._on_pos_done)
|
||||
|
||||
def _launch_color(self, fr):
|
||||
if fr not in self.live:
|
||||
return
|
||||
tgt = float(self.rng.choice((40, 255)))
|
||||
dur = self.rng.uniform(0.3, 0.9)
|
||||
fr.animate("fill_color.r", tgt, dur, mcrfpy.Easing.EASE_IN_OUT_SINE,
|
||||
callback=self._on_color_done)
|
||||
|
||||
def _on_pos_done(self, target, prop, val):
|
||||
self._launch_pos(target)
|
||||
|
||||
def _on_color_done(self, target, prop, val):
|
||||
self._launch_color(target)
|
||||
|
||||
def set_load(self, level_value):
|
||||
target_frames = max(1, int(level_value) // 2)
|
||||
while len(self.frames) < target_frames:
|
||||
self._new_frame()
|
||||
while len(self.frames) > target_frames:
|
||||
fr = self.frames.pop()
|
||||
self.live.discard(fr)
|
||||
try:
|
||||
self.ui.remove(fr)
|
||||
except Exception:
|
||||
pass
|
||||
self.load = len(self.frames) * 2
|
||||
|
||||
def teardown(self):
|
||||
self.live = set()
|
||||
self.frames = []
|
||||
super().teardown()
|
||||
92
tests/benchmarks/gauntlet/trials/entity_swarm.py
Normal file
92
tests/benchmarks/gauntlet/trials/entity_swarm.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
"""Trial 1: ENTITY SWARM -- entity step + render stress.
|
||||
|
||||
One 40x25 grid, N entities with SEEK behavior chasing a wandering target via a
|
||||
Dijkstra map, advanced with grid.step() on the sim tick. Load = entity count.
|
||||
"""
|
||||
import random
|
||||
|
||||
import mcrfpy
|
||||
|
||||
from . import Trial
|
||||
|
||||
GRID_W, GRID_H = 40, 25
|
||||
CELL = 24 # display px per cell (zoom applied)
|
||||
ARENA_X, ARENA_Y = 40, 110
|
||||
SEEK = int(mcrfpy.Behavior.SEEK)
|
||||
|
||||
|
||||
class EntitySwarm(Trial):
|
||||
key = "entity_swarm"
|
||||
name = "ENTITY SWARM"
|
||||
unit = "entities"
|
||||
accent = (245, 165, 36)
|
||||
description = "N seeking entities, grid.step() every tick"
|
||||
base_load = 60
|
||||
growth = 1.6
|
||||
|
||||
def setup(self, scene, ui):
|
||||
super().setup(scene, ui)
|
||||
self.rng = random.Random(0xE117)
|
||||
self.ticks = 0
|
||||
self.target = (GRID_W // 2, GRID_H // 2)
|
||||
|
||||
grid = mcrfpy.Grid(grid_size=(GRID_W, GRID_H),
|
||||
pos=(ARENA_X, ARENA_Y),
|
||||
size=(GRID_W * CELL, GRID_H * CELL))
|
||||
grid.zoom = CELL / 16.0
|
||||
grid.fill_color = mcrfpy.Color(18, 22, 30)
|
||||
for y in range(GRID_H):
|
||||
for x in range(GRID_W):
|
||||
c = grid.at(x, y)
|
||||
edge = (x == 0 or y == 0 or x == GRID_W - 1 or y == GRID_H - 1)
|
||||
c.walkable = not edge
|
||||
c.transparent = not edge
|
||||
ui.append(grid)
|
||||
self.grid = grid
|
||||
self.entities = []
|
||||
self.dmap = grid.get_dijkstra_map(self.target)
|
||||
grid.center_camera((GRID_W / 2.0, GRID_H / 2.0))
|
||||
|
||||
def _spawn(self):
|
||||
while True:
|
||||
x = self.rng.randint(1, GRID_W - 2)
|
||||
y = self.rng.randint(1, GRID_H - 2)
|
||||
if (x, y) != self.target:
|
||||
break
|
||||
e = mcrfpy.Entity((x, y), grid=self.grid)
|
||||
e.sprite_index = self.rng.choice((84, 85, 86, 100, 101))
|
||||
e.move_speed = 0.0
|
||||
e.set_behavior(SEEK, pathfinder=self.dmap)
|
||||
self.entities.append(e)
|
||||
|
||||
def set_load(self, level_value):
|
||||
target_n = max(1, int(level_value))
|
||||
while len(self.entities) < target_n:
|
||||
self._spawn()
|
||||
while len(self.entities) > target_n:
|
||||
e = self.entities.pop()
|
||||
try:
|
||||
e.die()
|
||||
except Exception:
|
||||
pass
|
||||
self.load = len(self.entities)
|
||||
|
||||
def _move_target(self):
|
||||
self.target = (self.rng.randint(1, GRID_W - 2),
|
||||
self.rng.randint(1, GRID_H - 2))
|
||||
self.grid.clear_dijkstra_maps()
|
||||
self.dmap = self.grid.get_dijkstra_map(self.target)
|
||||
for e in self.entities:
|
||||
e.set_behavior(SEEK, pathfinder=self.dmap)
|
||||
|
||||
def tick(self, dt_ms):
|
||||
self.ticks += 1
|
||||
if self.ticks % 6 == 0:
|
||||
self._move_target()
|
||||
self.grid.step()
|
||||
|
||||
def teardown(self):
|
||||
self.entities = []
|
||||
self.grid = None
|
||||
self.dmap = None
|
||||
super().teardown()
|
||||
87
tests/benchmarks/gauntlet/trials/grid_titan.py
Normal file
87
tests/benchmarks/gauntlet/trials/grid_titan.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""Trial 3: GRID TITAN -- grid render + layer-write stress.
|
||||
|
||||
Square SxS grid with a TileLayer base and a ColorLayer overlay. Every tick a
|
||||
32x32 ColorLayer region is rewritten (rolling window) and the camera orbits so
|
||||
render chunks keep invalidating. Load = S (grid side); cells = S*S.
|
||||
"""
|
||||
import math
|
||||
|
||||
import mcrfpy
|
||||
|
||||
from . import Trial
|
||||
|
||||
VIEW_PX = 620
|
||||
ARENA_X, ARENA_Y = 200, 115
|
||||
REGION = 32
|
||||
|
||||
|
||||
class GridTitan(Trial):
|
||||
key = "grid_titan"
|
||||
name = "GRID TITAN"
|
||||
unit = "grid side"
|
||||
accent = (53, 193, 214)
|
||||
description = "SxS grid, rolling color region + orbiting camera"
|
||||
base_load = 20
|
||||
growth = 1.4
|
||||
|
||||
def setup(self, scene, ui):
|
||||
super().setup(scene, ui)
|
||||
self.grid = None
|
||||
self.color = None
|
||||
self.angle = 0.0
|
||||
self.roll = 0
|
||||
self.side = 0
|
||||
|
||||
def _build(self, side):
|
||||
if self.grid is not None:
|
||||
try:
|
||||
self.ui.remove(self.grid)
|
||||
except Exception:
|
||||
pass
|
||||
self.side = side
|
||||
grid = mcrfpy.Grid(grid_size=(side, side),
|
||||
pos=(ARENA_X, ARENA_Y),
|
||||
size=(VIEW_PX, VIEW_PX),
|
||||
texture=mcrfpy.default_texture)
|
||||
grid.zoom = VIEW_PX / float(side * 16)
|
||||
grid.fill_color = mcrfpy.Color(10, 12, 18)
|
||||
base = mcrfpy.TileLayer(z_index=-2, name="base", texture=mcrfpy.default_texture)
|
||||
grid.add_layer(base)
|
||||
base.fill(0)
|
||||
color = mcrfpy.ColorLayer(z_index=-1, name="overlay")
|
||||
grid.add_layer(color)
|
||||
color.fill(mcrfpy.Color(20, 26, 38, 120))
|
||||
self.ui.append(grid)
|
||||
self.grid = grid
|
||||
self.color = color
|
||||
grid.center_camera((side / 2.0, side / 2.0))
|
||||
|
||||
def set_load(self, level_value):
|
||||
side = max(4, int(level_value))
|
||||
self._build(side)
|
||||
self.load = side
|
||||
|
||||
def tick(self, dt_ms):
|
||||
if self.grid is None:
|
||||
return
|
||||
s = self.side
|
||||
# Orbit the camera around the grid centre.
|
||||
self.angle += 0.18
|
||||
r = s * 0.15
|
||||
cx, cy = s / 2.0, s / 2.0
|
||||
self.grid.center_camera((cx + r * math.cos(self.angle),
|
||||
cy + r * math.sin(self.angle)))
|
||||
# Rewrite a rolling REGIONxREGION color window.
|
||||
w = min(REGION, s)
|
||||
span = max(1, s - w)
|
||||
self.roll = (self.roll + 3) % span
|
||||
ox = self.roll
|
||||
oy = (self.roll * 2) % span
|
||||
phase = (self.angle * 40) % 255
|
||||
col = mcrfpy.Color(int(phase), int(255 - phase), 160, 180)
|
||||
self.color.fill_rect((ox, oy), (w, w), col)
|
||||
|
||||
def teardown(self):
|
||||
self.grid = None
|
||||
self.color = None
|
||||
super().teardown()
|
||||
74
tests/benchmarks/gauntlet/trials/pathfinder_rush.py
Normal file
74
tests/benchmarks/gauntlet/trials/pathfinder_rush.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"""Trial 4: PATHFINDER RUSH -- A* query stress.
|
||||
|
||||
A static maze grid; every tick issues Q grid.find_path queries between random
|
||||
walkable pairs. Load = queries per tick.
|
||||
"""
|
||||
import random
|
||||
|
||||
import mcrfpy
|
||||
|
||||
from . import Trial
|
||||
|
||||
GRID_W, GRID_H = 60, 40
|
||||
CELL = 15
|
||||
ARENA_X, ARENA_Y = 60, 120
|
||||
|
||||
|
||||
class PathfinderRush(Trial):
|
||||
key = "pathfinder_rush"
|
||||
name = "PATHFINDER RUSH"
|
||||
unit = "queries/tick"
|
||||
accent = (76, 194, 110)
|
||||
description = "A* find_path queries across a static maze"
|
||||
base_load = 5
|
||||
growth = 1.6
|
||||
|
||||
def setup(self, scene, ui):
|
||||
super().setup(scene, ui)
|
||||
self.rng = random.Random(0x9A2E)
|
||||
self.queries = 0
|
||||
|
||||
grid = mcrfpy.Grid(grid_size=(GRID_W, GRID_H),
|
||||
pos=(ARENA_X, ARENA_Y),
|
||||
size=(GRID_W * CELL, GRID_H * CELL))
|
||||
grid.zoom = CELL / 16.0
|
||||
grid.fill_color = mcrfpy.Color(10, 14, 20)
|
||||
floor = mcrfpy.ColorLayer(z_index=-1, name="floor")
|
||||
grid.add_layer(floor)
|
||||
floor.fill(mcrfpy.Color(40, 54, 44))
|
||||
|
||||
wall_c = mcrfpy.Color(14, 20, 16)
|
||||
self.walkables = []
|
||||
for y in range(GRID_H):
|
||||
for x in range(GRID_W):
|
||||
c = grid.at(x, y)
|
||||
wall = (x == 0 or y == 0 or x == GRID_W - 1 or y == GRID_H - 1)
|
||||
# Pillar/room maze: isolated blockers keep the map connected but windy.
|
||||
if not wall and x % 4 == 2 and y % 3 == 1:
|
||||
wall = True
|
||||
c.walkable = not wall
|
||||
c.transparent = not wall
|
||||
if wall:
|
||||
floor.set((x, y), wall_c)
|
||||
else:
|
||||
self.walkables.append((x, y))
|
||||
ui.append(grid)
|
||||
self.grid = grid
|
||||
|
||||
def set_load(self, level_value):
|
||||
self.queries = max(1, int(level_value))
|
||||
self.load = self.queries
|
||||
|
||||
def tick(self, dt_ms):
|
||||
if self.grid is None:
|
||||
return
|
||||
w = self.walkables
|
||||
for _ in range(self.queries):
|
||||
a = w[self.rng.randrange(len(w))]
|
||||
b = w[self.rng.randrange(len(w))]
|
||||
self.grid.find_path(a, b)
|
||||
|
||||
def teardown(self):
|
||||
self.grid = None
|
||||
self.walkables = []
|
||||
super().teardown()
|
||||
100
tests/benchmarks/gauntlet/trials/sightline_siege.py
Normal file
100
tests/benchmarks/gauntlet/trials/sightline_siege.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"""Trial 6: SIGHTLINE SIEGE -- FOV + perspective writeback stress.
|
||||
|
||||
A grid with scattered walls; N entities each recompute an active FOV (radius 10)
|
||||
via update_visibility() on the sim tick as they random-walk. Load = FOV entities.
|
||||
"""
|
||||
import random
|
||||
|
||||
import mcrfpy
|
||||
|
||||
from . import Trial
|
||||
|
||||
GRID_W, GRID_H = 50, 32
|
||||
CELL = 18
|
||||
ARENA_X, ARENA_Y = 60, 118
|
||||
SIGHT = 10
|
||||
STEPS = ((1, 0), (-1, 0), (0, 1), (0, -1))
|
||||
|
||||
|
||||
class SightlineSiege(Trial):
|
||||
key = "sightline_siege"
|
||||
name = "SIGHTLINE SIEGE"
|
||||
unit = "FOV entities"
|
||||
accent = (229, 72, 77)
|
||||
description = "N random-walking entities, per-entity FOV recompute"
|
||||
base_load = 20
|
||||
growth = 1.6
|
||||
|
||||
def setup(self, scene, ui):
|
||||
super().setup(scene, ui)
|
||||
self.rng = random.Random(0xF0F0)
|
||||
|
||||
grid = mcrfpy.Grid(grid_size=(GRID_W, GRID_H),
|
||||
pos=(ARENA_X, ARENA_Y),
|
||||
size=(GRID_W * CELL, GRID_H * CELL),
|
||||
texture=mcrfpy.default_texture)
|
||||
grid.zoom = CELL / 16.0
|
||||
grid.fill_color = mcrfpy.Color(8, 10, 16)
|
||||
grid.fov_radius = SIGHT
|
||||
floor = mcrfpy.ColorLayer(z_index=-1, name="floor")
|
||||
grid.add_layer(floor)
|
||||
floor.fill(mcrfpy.Color(38, 30, 34))
|
||||
wall_c = mcrfpy.Color(16, 12, 14)
|
||||
|
||||
self.walkables = []
|
||||
for y in range(GRID_H):
|
||||
for x in range(GRID_W):
|
||||
c = grid.at(x, y)
|
||||
wall = (x == 0 or y == 0 or x == GRID_W - 1 or y == GRID_H - 1)
|
||||
if not wall and self.rng.random() < 0.16:
|
||||
wall = True
|
||||
c.walkable = not wall
|
||||
c.transparent = not wall
|
||||
if wall:
|
||||
floor.set((x, y), wall_c)
|
||||
else:
|
||||
self.walkables.append((x, y))
|
||||
ui.append(grid)
|
||||
self.grid = grid
|
||||
self.entities = []
|
||||
|
||||
def _spawn(self):
|
||||
x, y = self.walkables[self.rng.randrange(len(self.walkables))]
|
||||
e = mcrfpy.Entity((x, y), grid=self.grid)
|
||||
e.sprite_index = 90
|
||||
e.sight_radius = SIGHT
|
||||
e.move_speed = 0.0
|
||||
e.update_visibility()
|
||||
self.entities.append(e)
|
||||
|
||||
def set_load(self, level_value):
|
||||
target_n = max(1, int(level_value))
|
||||
while len(self.entities) < target_n:
|
||||
self._spawn()
|
||||
while len(self.entities) > target_n:
|
||||
e = self.entities.pop()
|
||||
try:
|
||||
e.die()
|
||||
except Exception:
|
||||
pass
|
||||
self.load = len(self.entities)
|
||||
|
||||
def tick(self, dt_ms):
|
||||
g = self.grid
|
||||
for e in self.entities:
|
||||
x, y = e.grid_x, e.grid_y
|
||||
dirs = list(STEPS)
|
||||
self.rng.shuffle(dirs)
|
||||
for dx, dy in dirs:
|
||||
nx, ny = x + dx, y + dy
|
||||
if 0 <= nx < GRID_W and 0 <= ny < GRID_H and g.at(nx, ny).walkable:
|
||||
e.grid_x = nx
|
||||
e.grid_y = ny
|
||||
break
|
||||
e.update_visibility()
|
||||
|
||||
def teardown(self):
|
||||
self.entities = []
|
||||
self.grid = None
|
||||
self.walkables = []
|
||||
super().teardown()
|
||||
95
tests/benchmarks/gauntlet/trials/ui_avalanche.py
Normal file
95
tests/benchmarks/gauntlet/trials/ui_avalanche.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"""Trial 5: UI AVALANCHE -- UI hierarchy + draw-call stress.
|
||||
|
||||
Nested Frame trees (depth 5), each level carrying a Caption and a Sprite, plus a
|
||||
z-order shuffle of the top-level frames every tick to defeat render caching.
|
||||
Load = total UI elements.
|
||||
"""
|
||||
import random
|
||||
|
||||
import mcrfpy
|
||||
|
||||
from . import Trial
|
||||
|
||||
ARENA_X, ARENA_Y = 20, 108
|
||||
COL_W, ROW_H = 132, 132
|
||||
COLS = 7
|
||||
DEPTH = 5
|
||||
PER_TREE = 15 # 1 root frame + 5*(caption+sprite) + 4 nested frames
|
||||
|
||||
|
||||
class UIAvalanche(Trial):
|
||||
key = "ui_avalanche"
|
||||
name = "UI AVALANCHE"
|
||||
unit = "elements"
|
||||
accent = (154, 110, 245)
|
||||
description = "Depth-5 frame trees, z-shuffled every tick"
|
||||
base_load = 60
|
||||
growth = 1.6
|
||||
|
||||
def setup(self, scene, ui):
|
||||
super().setup(scene, ui)
|
||||
self.rng = random.Random(0x5A1A)
|
||||
self.trees = [] # list of root Frames
|
||||
self.total = 0
|
||||
|
||||
def _build_tree(self, index):
|
||||
col = index % COLS
|
||||
row = index // COLS
|
||||
x = ARENA_X + col * COL_W
|
||||
y = ARENA_Y + row * ROW_H
|
||||
root = mcrfpy.Frame(pos=(x, y), size=(120, 120))
|
||||
root.fill_color = mcrfpy.Color(self.rng.randint(30, 90),
|
||||
self.rng.randint(20, 70),
|
||||
self.rng.randint(60, 120))
|
||||
root.outline = 1
|
||||
root.outline_color = mcrfpy.Color(154, 110, 245)
|
||||
self.ui.append(root)
|
||||
count = 1
|
||||
parent = root
|
||||
size = 120
|
||||
for d in range(DEPTH):
|
||||
cap = mcrfpy.Caption(text="L%d" % d, pos=(4, 2))
|
||||
cap.fill_color = mcrfpy.Color(220, 220, 240)
|
||||
parent.children.append(cap)
|
||||
count += 1
|
||||
spr = mcrfpy.Sprite(pos=(4, 18), texture=mcrfpy.default_texture,
|
||||
sprite_index=self.rng.randint(0, 120))
|
||||
parent.children.append(spr)
|
||||
count += 1
|
||||
if d < DEPTH - 1:
|
||||
size -= 18
|
||||
child = mcrfpy.Frame(pos=(10, 30), size=(size, size))
|
||||
child.fill_color = mcrfpy.Color(self.rng.randint(30, 90),
|
||||
self.rng.randint(20, 70),
|
||||
self.rng.randint(60, 120))
|
||||
parent.children.append(child)
|
||||
count += 1
|
||||
parent = child
|
||||
self.trees.append(root)
|
||||
self.total += count
|
||||
|
||||
def set_load(self, level_value):
|
||||
target = max(PER_TREE, int(level_value))
|
||||
while self.total < target:
|
||||
self._build_tree(len(self.trees))
|
||||
# Shed whole trees while we can stay at or above target.
|
||||
while self.trees and (self.total - PER_TREE) >= target:
|
||||
root = self.trees.pop()
|
||||
try:
|
||||
self.ui.remove(root)
|
||||
except Exception:
|
||||
pass
|
||||
self.total -= PER_TREE
|
||||
self.load = self.total
|
||||
|
||||
def tick(self, dt_ms):
|
||||
# Shuffle z-order of top-level frames to invalidate caches.
|
||||
order = list(range(len(self.trees)))
|
||||
self.rng.shuffle(order)
|
||||
for z, root in zip(order, self.trees):
|
||||
root.z_index = z
|
||||
|
||||
def teardown(self):
|
||||
self.trees = []
|
||||
self.total = 0
|
||||
super().teardown()
|
||||
Loading…
Add table
Add a link
Reference in a new issue