WenyiWU0111 commited on
Commit
f12c058
·
verified ·
1 Parent(s): 4b1835b

Add the build scripts and docs; one index.html for every host

Browse files
.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ media
2
+ __pycache__/
3
+ _shots/
README.md CHANGED
@@ -7,4 +7,97 @@ sdk: static
7
  pinned: false
8
  ---
9
 
10
- EvoGame project page (preview).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  pinned: false
8
  ---
9
 
10
+ # EvoGame — project page
11
+
12
+ Static site: plain HTML/CSS/JS, no build step, no framework. It shows every game we ran EvoGame on
13
+ twice — the initial generation (round 0) and the same game after 30 EvoGame rounds — plus one game
14
+ that visitors play in the browser.
15
+
16
+ Live: https://wenyiwu0111-evogame-page.static.hf.space (a public HF static Space)
17
+
18
+ ## Run it locally
19
+
20
+ ```bash
21
+ python -m http.server 8765 # then open http://127.0.0.1:8765
22
+ ```
23
+
24
+ The page `fetch()`es JSON, so open it over HTTP, not as a `file://` path.
25
+
26
+ Video, images and the playable builds are **not in this repo** (see *Media* below). By default they
27
+ stream from the published site, so a fresh clone works with no extra files. To use a local copy
28
+ instead, put it (or a symlink to it) at `./media` and open the page with `?media=local`:
29
+
30
+ ```bash
31
+ ln -s /path/to/evogame-page-media media # then http://127.0.0.1:8765/?media=local
32
+ ```
33
+
34
+ ## Layout
35
+
36
+ ```
37
+ index.html the page: game screen, hero, before/after, gallery, results, citation
38
+ play.html standalone Godot web player (one shared engine, one game pack per build)
39
+ static/css/base.css tokens + everything shared (cards, modal, charts, wipe comparison)
40
+ static/css/v1.css this design's own pieces (game screen, spotlight, pair cards)
41
+ static/js/core.js data loading, video scheduling, before/after wipe, game dialog, charts
42
+ static/js/v1.js this page: what goes where
43
+ static/data/evo.json one row per game: round-0 and round-30 scores, chosen demo, score curve
44
+ static/data/evo/<arm>/<task>.json per-requirement before/after + judge rationales + 30-round timeline
45
+ static/data/play.json the playable builds (engine + per-round .pck sizes)
46
+ static/data/games.json the 280 initial generations across both engines (used by v2–v4 only)
47
+ _build/*.py regenerate everything from the experiment runs (see below)
48
+ v2/ v3/ v4/ earlier design drafts kept for reference; the live page is v1 (root)
49
+ ```
50
+
51
+ `static/js/core.js` is shared by all four designs — change it carefully.
52
+
53
+ ## Media (not in this repo)
54
+
55
+ Built into `/storage/admin/wenyi/evogame-page-media` on the lab machine:
56
+
57
+ ```
58
+ pairs/<arm>/<task>/p0.mp4, final.mp4, p0.webp, final.webp the same demo on both builds (54 MB)
59
+ play/engine/godot.js, godot.wasm.gz, godot.audio*.js one shared Godot 4.6.2 web engine (9 MB gz)
60
+ play/<arm>/<task>/<r0|r30>/index.pck one game pack per playable build
61
+ hero_evo.mp4 / .webp the moving background: 30 games in one video
62
+ baseline/... clips for the v2–v4 drafts
63
+ ```
64
+
65
+ Everything the live site needs is also in the Space repo, so the quickest way to get a full working
66
+ copy is to clone the Space (below).
67
+
68
+ ## Rebuild the data
69
+
70
+ Needs the experiment runs on the lab machine, and Godot 4.6.2 with web export templates for `build_web.py`.
71
+
72
+ ```bash
73
+ python _build/build_pairs.py # before/after clips + evo.json + per-game detail JSON
74
+ python _build/build_hero.py --evo --out hero_evo --cols 5 --rows 6 --tile 320x180 --gap 3
75
+ python _build/build_web.py --games godot_qwen/platformer-dig-descent # playable web builds
76
+ python _build/build_media.py # only for the v2–v4 drafts (280 baseline games)
77
+ ```
78
+
79
+ `build_pairs.py --reuse-media` rebuilds the JSON without re-encoding video.
80
+
81
+ ## Deploy
82
+
83
+ The live site is the HF Space `WenyiWU0111/evogame-page` (sdk: static). It holds the page **and** its
84
+ media, so a clone is a complete, runnable copy:
85
+
86
+ ```bash
87
+ git clone https://huggingface.co/spaces/WenyiWU0111/evogame-page
88
+ cd evogame-page && python -m http.server 8765
89
+ ```
90
+
91
+ Push to that repo to publish (needs write access on the Space). The Space copy differs from this repo
92
+ in two small ways, both injected at deploy time: `window.EVO_MEDIA = "media"` pins the media folder,
93
+ and `window.EVO_NO_SWITCH = true` hides the v1–v4 switcher.
94
+
95
+ ## Notes for whoever picks this up
96
+
97
+ - Scores are ×100 of the GameCraft-Bench reward, judged by Qwen3.8-27B over three passes; round 0 and
98
+ round 30 use the same scorer, judge and demo scripts, so the two sides are comparable.
99
+ - Six games are missing from the gallery because their round 30 had not finished scoring; rerun
100
+ `build_pairs.py` to pick them up.
101
+ - Authors, paper/code/dataset links and the BibTeX entry are still placeholders in `index.html`.
102
+ - Videos never autoplay more than a handful at a time: cards hold a poster until they scroll into
103
+ view, at most 12 clips play at once, and the hero is one pre-composited video rather than 30.
_build/build_hero.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Composite the hero background: a COLSxROWS grid of highlight clips as ONE looping video.
2
+
3
+ One decoder instead of COLS*ROWS - the page stays smooth. Picks the top games by reward,
4
+ alternating engines, skipping duplicate tasks.
5
+
6
+ python _build/build_hero.py [--cols 6 --rows 3]
7
+ """
8
+ import argparse, io, json, subprocess
9
+ from pathlib import Path
10
+
11
+ from PIL import Image
12
+
13
+ PAGE = Path(__file__).resolve().parents[1]
14
+ MEDIA = Path("/storage/admin/wenyi/evogame-page-media")
15
+
16
+ ap = argparse.ArgumentParser()
17
+ ap.add_argument("--cols", type=int, default=6)
18
+ ap.add_argument("--rows", type=int, default=3)
19
+ ap.add_argument("--seconds", type=float, default=10)
20
+ ap.add_argument("--evo", action="store_true", help="use the round-30 clips of the before/after pairs")
21
+ ap.add_argument("--out", default="hero_mosaic")
22
+ ap.add_argument("--tile", default="320x180", help="cell size in the composite, WxH")
23
+ ap.add_argument("--gap", type=int, default=0, help="dark gutter inside each cell, px")
24
+ a = ap.parse_args()
25
+ n = a.cols * a.rows
26
+
27
+ if a.evo:
28
+ games = json.loads((PAGE / "static/data/evo.json").read_text())
29
+ for g in games:
30
+ g["clip"] = MEDIA / g["pair"] / "final.mp4"
31
+ g["reward"] = g["repaired"]["reward"]
32
+ else:
33
+ games = [g for g in json.loads((PAGE / "static/data/games.json").read_text()) if g["media"]]
34
+ for g in games:
35
+ g["clip"] = MEDIA / g["media"] / "highlight.mp4"
36
+ by_arm = {}
37
+ for g in sorted(games, key=lambda g: -g["reward"]):
38
+ by_arm.setdefault(g["arm"], []).append(g)
39
+ picked, seen = [], set()
40
+ while len(picked) < n and any(by_arm.values()):
41
+ for arm in list(by_arm):
42
+ while by_arm[arm]:
43
+ g = by_arm[arm].pop(0)
44
+ if g["task"] not in seen:
45
+ picked.append(g); seen.add(g["task"]); break
46
+ picked = picked[:n]
47
+
48
+ W, H = map(int, a.tile.split("x"))
49
+ g2 = a.gap * 2
50
+ cmd = ["ffmpeg", "-y"]
51
+ for g in picked:
52
+ cmd += ["-stream_loop", "-1", "-ss", "2", "-t", str(a.seconds), "-i", str(g["clip"])]
53
+ fl = [f"[{i}:v]scale={W - g2}:{H - g2},pad={W}:{H}:{a.gap}:{a.gap}:color=0x05060a,setsar=1,fps=24[v{i}]" for i in range(n)]
54
+ layout = "|".join(f"{(i % a.cols) * W}_{(i // a.cols) * H}" for i in range(n))
55
+ fl.append("".join(f"[v{i}]" for i in range(n)) + f"xstack=inputs={n}:layout={layout}[out]")
56
+ out = MEDIA / f"{a.out}.mp4"
57
+ cmd += ["-filter_complex", ";".join(fl), "-map", "[out]", "-t", str(a.seconds), "-c:v", "libx264",
58
+ "-preset", "slow", "-crf", "30", "-pix_fmt", "yuv420p", "-movflags", "+faststart", "-an", str(out)]
59
+ subprocess.run(cmd, check=True, stderr=subprocess.DEVNULL)
60
+ png = subprocess.run(["ffmpeg", "-ss", "1", "-i", str(out), "-frames:v", "1", "-f", "image2pipe", "-c:v", "png", "-"],
61
+ check=True, capture_output=True).stdout
62
+ Image.open(io.BytesIO(png)).save(MEDIA / f"{a.out}.webp", "WEBP", quality=70)
63
+ print(out, out.stat().st_size // 1024, "KB;", ", ".join(g["task"] for g in picked))
_build/build_media.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build the page's media + data from the r0 baseline scoring runs.
2
+
3
+ For every (arm, task) it writes, under MEDIA_OUT/baseline/<arm>/<task>/:
4
+ highlight.mp4 ~10 s montage of the most active window of up to 4 demos (640x360, no audio)
5
+ poster.webp the busiest frame, for the card before the video loads
6
+ demos/<id>.mp4 every pass-1 demo, remuxed with faststart so it streams
7
+ and into the page repo:
8
+ static/data/games.json one row per game (what the gallery needs)
9
+ static/data/details/<arm>/<task>.json per-requirement scores + judge rationales (loaded on open)
10
+
11
+ Scores come from pass 1 of r0_scores (judge qwen38-27b); reward/M/D/V/A are the CSV means.
12
+
13
+ python _build/build_media.py --arms godot_gpt phaser_gpt [--tasks a b] [--jobs 16] [--data-only]
14
+ """
15
+ import argparse, csv, json, re, subprocess, sys
16
+ from concurrent.futures import ProcessPoolExecutor
17
+ from pathlib import Path
18
+
19
+ import numpy as np
20
+ from PIL import Image
21
+
22
+ SCORES = Path("/storage/admin/wenyi/r0_scores")
23
+ MEDIA_OUT = Path("/storage/admin/wenyi/evogame-page-media")
24
+ PAGE = Path(__file__).resolve().parents[1]
25
+ TASKS = {"godot": Path("/home/admin/wenyi/gamecraft-bench/tasks"),
26
+ "phaser": Path("/home/admin/wenyi/gamecraft-bench-web/tasks-web")}
27
+ ARMS = {
28
+ "godot_gpt": {"engine": "Godot 4", "model": "GPT-5.5", "harness": "Codex CLI"},
29
+ "phaser_gpt": {"engine": "Phaser", "model": "GPT-5.5", "harness": "OpenGame"},
30
+ "godot_qwen": {"engine": "Godot 4", "model": "Qwen3.8-27B", "harness": ""},
31
+ "phaser_qwen": {"engine": "Phaser", "model": "Qwen3.8-27B", "harness": ""},
32
+ "godot_kimi": {"engine": "Godot 4", "model": "Kimi-K2.6", "harness": ""},
33
+ "godot_glm": {"engine": "Godot 4", "model": "GLM-5.3-Flash", "harness": ""},
34
+ }
35
+ CATS = {"M": "Core Mechanics", "D": "Content Depth", "V": "Functional Visuals", "A": "Presentation & Art"}
36
+ SEG_S, MAX_SEGS, FRAME_STEP_S = 2.5, 4, 0.5
37
+
38
+
39
+ def run(cmd):
40
+ subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
41
+
42
+
43
+ def instruction_meta(engine_key, task):
44
+ md = (TASKS[engine_key] / task / "instruction.md").read_text()
45
+ title = re.search(r"^# (.+)$", md, re.M).group(1).strip()
46
+ m = re.search(r"## Core Vision\s+(.+?)(?:\n## |\Z)", md, re.S)
47
+ vision = re.sub(r"\s+", " ", m.group(1)).strip() if m else ""
48
+ toml = (TASKS[engine_key] / task / "task.toml").read_text()
49
+ d = re.search(r'^description\s*=\s*"(.*)"', toml, re.M)
50
+ return title, (d.group(1) if d else ""), vision
51
+
52
+
53
+ def padded_from(task_dir):
54
+ """demo_id -> first padded frame index (frozen tail we should not show)."""
55
+ p = task_dir / "tail_padding.json"
56
+ if not p.exists():
57
+ return {}
58
+ return {x["demo"]: x["from"] for x in json.loads(p.read_text()).get("padded", [])}
59
+
60
+
61
+ def activity(frames):
62
+ """Per-step visual change between consecutive 0.5 s frames."""
63
+ thumbs = [np.asarray(Image.open(f).convert("L").resize((64, 36)), dtype=np.float32) for f in frames]
64
+ return [float(np.abs(a - b).mean()) for a, b in zip(thumbs, thumbs[1:])]
65
+
66
+
67
+ def best_window(demo):
68
+ """(start_s, score, busiest_frame) of the most active SEG_S window in the unpadded part."""
69
+ frames = sorted((demo["dir"] / "frames").glob("frame_*.png"))
70
+ usable = demo["dur"] - 0.3
71
+ if demo["pad_from_frame"] is not None:
72
+ usable = min(usable, demo["pad_from_frame"] / 30.0)
73
+ n = int(usable / FRAME_STEP_S)
74
+ frames = frames[: max(n, 2)]
75
+ if len(frames) < 2:
76
+ return 0.0, 0.0, frames[0] if frames else None
77
+ act = activity(frames)
78
+ w = max(1, int(SEG_S / FRAME_STEP_S))
79
+ sums = [sum(act[i:i + w]) for i in range(max(1, len(act) - w + 1))]
80
+ i = int(np.argmax(sums))
81
+ start = min(i * FRAME_STEP_S, max(0.0, usable - SEG_S))
82
+ busiest = frames[i + 1 + int(np.argmax(act[i:i + w]))] if act[i:i + w] else frames[i]
83
+ return start, sums[i], busiest
84
+
85
+
86
+ def build_game(arm, task, data_only=False):
87
+ tdir = SCORES / arm / task
88
+ p1 = tdir / "p1"
89
+ out = MEDIA_OUT / "baseline" / arm / task
90
+ pads = padded_from(tdir)
91
+ demos = []
92
+ if (p1 / "demos").is_dir():
93
+ for d in sorted((p1 / "demos").iterdir()):
94
+ mp4 = d / f"{d.name}.mp4"
95
+ if not mp4.exists():
96
+ continue
97
+ dur = float(subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration",
98
+ "-of", "csv=p=0", str(mp4)], capture_output=True, text=True).stdout or 0)
99
+ demos.append({"id": d.name, "dir": d, "mp4": mp4, "dur": dur, "pad_from_frame": pads.get(d.name)})
100
+ has_media = bool(demos)
101
+ if has_media and not (data_only and (out / "highlight.mp4").exists()):
102
+ (out / "demos").mkdir(parents=True, exist_ok=True)
103
+ for d in demos:
104
+ run(["ffmpeg", "-y", "-i", str(d["mp4"]), "-c", "copy", "-an", "-movflags", "+faststart",
105
+ str(out / "demos" / f"{d['id']}.mp4")])
106
+ wins = [(d, *best_window(d)) for d in demos]
107
+ # the MAX_SEGS most active demos, shown in their original order
108
+ picked = sorted(sorted(wins, key=lambda x: -x[2])[:MAX_SEGS], key=lambda x: x[0]["id"])
109
+ cmd, fl = ["ffmpeg", "-y"], []
110
+ for k, (d, start, _, _) in enumerate(picked):
111
+ cmd += ["-ss", f"{start:.2f}", "-t", str(SEG_S), "-i", str(d["mp4"])]
112
+ fl.append(f"[{k}:v]scale=640:360:force_original_aspect_ratio=decrease,pad=640:360:(ow-iw)/2:(oh-ih)/2,"
113
+ f"fps=24,setsar=1,format=yuv420p[v{k}]")
114
+ fl.append("".join(f"[v{k}]" for k in range(len(picked))) + f"concat=n={len(picked)}:v=1:a=0[out]")
115
+ cmd += ["-filter_complex", ";".join(fl), "-map", "[out]", "-c:v", "libx264", "-preset", "slow",
116
+ "-crf", "30", "-movflags", "+faststart", "-an", str(out / "highlight.mp4")]
117
+ run(cmd)
118
+ busiest = max(wins, key=lambda x: x[2])[3]
119
+ Image.open(busiest).convert("RGB").resize((640, 360)).save(out / "poster.webp", "WEBP", quality=72)
120
+ return arm, task, [{"id": d["id"], "dur": round(d["dur"], 1)} for d in demos], has_media
121
+
122
+
123
+ def details(arm, task):
124
+ p1 = SCORES / arm / task / "p1"
125
+ bpath = p1 / "breakdown.json"
126
+ if not bpath.exists():
127
+ return None
128
+ b = json.loads(bpath.read_text())
129
+ rationale = {}
130
+ jl = p1 / "judge_log.json"
131
+ if jl.exists():
132
+ for r in json.loads(jl.read_text()):
133
+ rationale.setdefault(r["requirement_id"], {})[r["demo_id"]] = r.get("rationale", "")
134
+ return {
135
+ "reward_p1": b.get("reward"), "build_ok": b.get("build_ok"), "formula": b.get("formula"),
136
+ "judge": b.get("judge", {}).get("model"),
137
+ "requirements": [{
138
+ "id": r["id"], "cat": CATS.get(r["id"][0], ""), "description": r["description"],
139
+ "score": r.get("aggregated"), "agg": r.get("agg"),
140
+ "per_demo": r.get("per_demo", {}), "rationale": rationale.get(r["id"], {}),
141
+ } for r in b.get("requirements", [])],
142
+ }
143
+
144
+
145
+ def main():
146
+ ap = argparse.ArgumentParser()
147
+ ap.add_argument("--arms", nargs="+", default=["godot_gpt", "phaser_gpt"])
148
+ ap.add_argument("--tasks", nargs="*")
149
+ ap.add_argument("--jobs", type=int, default=16)
150
+ ap.add_argument("--data-only", action="store_true", help="skip media that already exists")
151
+ a = ap.parse_args()
152
+
153
+ rows = {}
154
+ for arm in a.arms:
155
+ for r in csv.DictReader(open(SCORES / f"{arm}.csv")):
156
+ if not a.tasks or r["task"] in a.tasks:
157
+ rows[(arm, r["task"])] = r
158
+
159
+ media = {}
160
+ with ProcessPoolExecutor(a.jobs) as ex:
161
+ futs = [ex.submit(build_game, arm, task, a.data_only) for arm, task in rows]
162
+ for f in futs:
163
+ try:
164
+ arm, task, demos, has_media = f.result()
165
+ media[(arm, task)] = (demos, has_media)
166
+ print("ok", arm, task, len(demos), flush=True)
167
+ except subprocess.CalledProcessError as e:
168
+ print("FAIL", e.cmd[-1], e.stderr.decode()[-400:], file=sys.stderr, flush=True)
169
+
170
+ data = PAGE / "static" / "data"
171
+ games = []
172
+ for (arm, task), r in rows.items():
173
+ if (arm, task) not in media:
174
+ continue
175
+ engine_key = arm.split("_")[0]
176
+ title, blurb, vision = instruction_meta(engine_key, task)
177
+ demos, has_media = media[(arm, task)]
178
+ det = details(arm, task)
179
+ if det:
180
+ (data / "details" / arm).mkdir(parents=True, exist_ok=True)
181
+ (data / "details" / arm / f"{task}.json").write_text(json.dumps(det, ensure_ascii=False))
182
+ f = lambda k: round(float(r[k]), 4)
183
+ games.append({
184
+ "arm": arm, "task": task, **ARMS[arm], "genre": task.split("-")[0],
185
+ "title": title, "blurb": blurb, "vision": vision,
186
+ "reward": f("reward"), "build": f("BUILD"), "M": f("M"), "D": f("D"), "V": f("V"), "A": f("A"),
187
+ "media": f"baseline/{arm}/{task}" if has_media else None, "demos": demos,
188
+ "repaired": None,
189
+ })
190
+ games.sort(key=lambda g: (g["arm"], -g["reward"]))
191
+ data.mkdir(parents=True, exist_ok=True)
192
+ (data / "games.json").write_text(json.dumps(games, ensure_ascii=False, indent=0))
193
+ print(f"wrote {len(games)} games")
194
+
195
+
196
+ if __name__ == "__main__":
197
+ main()
_build/build_pairs.py ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Before/after pairs: the initial generation (round 0) next to the game after 30 EvoGame rounds.
2
+
3
+ For every game with a scored round 30 it writes, under MEDIA_OUT/pairs/<arm>/<task>/:
4
+ p0.mp4 / final.mp4 the SAME demo replayed on both builds, cut to the same length (no padded tail, <= MAX_S)
5
+ p0.webp / final.webp stills taken at the same moment of each clip
6
+ and into the page repo:
7
+ static/data/evo.json one row per game: scores before/after, the chosen demo, score curve
8
+ static/data/evo/<arm>/<task>.json per-requirement before/after + judge rationales + the 30-round timeline
9
+
10
+ Both sides are scored by the same scorer/judge (qwen38-27b, 3 passes); scores are pass means, categories are
11
+ pass means of the rubric variables. The demo shown is the common demo whose per-demo judge score rose the most.
12
+
13
+ python _build/build_pairs.py [--arms godot_gpt godot_qwen] [--tasks ...] [--jobs 16]
14
+ """
15
+ import argparse, json, re, subprocess, sys
16
+ from io import BytesIO
17
+ from concurrent.futures import ProcessPoolExecutor
18
+ from pathlib import Path
19
+
20
+ from PIL import Image
21
+
22
+ MEDIA_OUT = Path("/storage/admin/wenyi/evogame-page-media")
23
+ PAGE = Path(__file__).resolve().parents[1]
24
+ TASKS = Path("/home/admin/wenyi/gamecraft-bench/tasks")
25
+ FINAL_ROUND = 30
26
+ MAX_S = 20.0
27
+ FPS = 30 # Godot scorer recordings
28
+ REUSE = "--reuse-media" in sys.argv # keep already-encoded clips (data-only rebuild)
29
+
30
+ ARMS = {
31
+ "godot_gpt": {
32
+ "engine": "Godot 4", "model": "GPT-5.5", "harness": "Codex CLI",
33
+ "p0": lambda g: Path(f"/storage/admin/wenyi/r0_scores/godot_gpt/{g}"),
34
+ "loop": Path("/storage/admin/wenyi/newloop_self40/runs"),
35
+ },
36
+ "godot_qwen": {
37
+ "engine": "Godot 4", "model": "Qwen3.8-27B", "harness": "",
38
+ # the loop started from corpus_qwen; this is round 0 of exactly that build, scored the same way
39
+ "p0": lambda g: Path(f"/storage/admin/scored/qwen_{g}__G0"),
40
+ "loop": Path("/home/admin/wenyi/gamedoctor_run/newloop_selfqwen40/runs"),
41
+ },
42
+ }
43
+ CATS = {"M": "Core Mechanics", "D": "Content Depth", "V": "Functional Visuals", "A": "Presentation & Art"}
44
+
45
+
46
+ def clip_text(t, n):
47
+ t = re.sub(r"\s+", " ", t or "").strip()
48
+ return t if len(t) <= n else t[: n - 1].rsplit(" ", 1)[0] + "…"
49
+
50
+
51
+ def score_dir_ok(d):
52
+ s = d / "summary.json"
53
+ return s.exists() and not (d / "score_failed.txt").exists() and (d / "p1" / "breakdown.json").exists()
54
+
55
+
56
+ def scores(d):
57
+ """reward and category means over the judging passes of one score dir."""
58
+ rewards = json.loads((d / "summary.json").read_text())["rewards"]
59
+ cats = {k: [] for k in CATS}
60
+ build = []
61
+ for p in sorted(d.glob("p[0-9]/breakdown.json")):
62
+ b = json.loads(p.read_text())
63
+ v = b.get("variables", {})
64
+ build.append(float(v.get("BUILD", 1.0 if b.get("build_ok") else 0.0)))
65
+ for k in CATS:
66
+ xs = [float(x) for n, x in v.items() if re.fullmatch(fr"{k}\d+", n)]
67
+ if xs:
68
+ cats[k].append(sum(xs) / len(xs))
69
+ out = {"reward": round(sum(rewards) / len(rewards), 4), "build": round(sum(build) / len(build), 2) if build else 0}
70
+ out.update({k: round(sum(xs) / len(xs), 4) if xs else 0.0 for k, xs in cats.items()})
71
+ return out
72
+
73
+
74
+ def pad_from(d):
75
+ p = d / "tail_padding.json"
76
+ if not p.exists():
77
+ return {}
78
+ return {x["demo"]: x["from"] / FPS for x in json.loads(p.read_text()).get("padded", []) if x.get("from") is not None}
79
+
80
+
81
+ def duration(mp4):
82
+ return float(subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", str(mp4)],
83
+ capture_output=True, text=True).stdout or 0)
84
+
85
+
86
+ def per_demo_mean(breakdown):
87
+ acc = {}
88
+ for r in breakdown["requirements"]:
89
+ for demo, s in r.get("per_demo", {}).items():
90
+ acc.setdefault(demo, []).append(float(s))
91
+ return {d: sum(v) / len(v) for d, v in acc.items()}
92
+
93
+
94
+ def rationales(d):
95
+ out = {}
96
+ jl = d / "p1" / "judge_log.json"
97
+ if jl.exists():
98
+ for r in json.loads(jl.read_text()):
99
+ out.setdefault(r["requirement_id"], {})[r["demo_id"]] = r.get("rationale", "")
100
+ return out
101
+
102
+
103
+ def round_goal(e, plan):
104
+ """What the round set out to do. Art rounds carry their goal in art/polish, not in the plan question."""
105
+ q = plan.get("question") or e.get("question") or ""
106
+ if q and not q.lower().startswith("no development question"):
107
+ return q
108
+ for src in (e.get("art"), e.get("polish")):
109
+ if isinstance(src, dict) and src.get("goal"):
110
+ return src["goal"]
111
+ landed = (e.get("art_list") or {}).get("landed") or []
112
+ asked = [x.get("asked") for x in landed if isinstance(x, dict) and x.get("asked")]
113
+ return "Art: " + "; ".join(asked[:3]) if asked else ""
114
+
115
+
116
+ def timeline(loop_game):
117
+ rj = loop_game / "rounds.json"
118
+ if not rj.exists():
119
+ return []
120
+ out = []
121
+ for e in json.loads(rj.read_text()):
122
+ plan = e.get("plan") or {}
123
+ ver = e.get("verification") or {}
124
+ prim = next((i for i in ver.get("issues", []) if i.get("role") == "primary"), None)
125
+ verdict = ver.get("primary_verdict") or (prim or {}).get("verdict")
126
+ out.append({
127
+ "r": e.get("round"),
128
+ "kind": "art" if e.get("kind") == "art" else "repair",
129
+ "ask": clip_text(round_goal(e, plan), 240),
130
+ "found": clip_text(e.get("primary_issue") or "", 220),
131
+ "verdict": verdict,
132
+ "note": clip_text((prim or {}).get("note") or "", 260),
133
+ "files": len((e.get("repair") or {}).get("files_edited") or []),
134
+ "assets": (e.get("generated_files") or {}).get("n", 0),
135
+ "build_ok": e.get("build_ok"),
136
+ })
137
+ return out
138
+
139
+
140
+ def build_game(arm, task):
141
+ cfg = ARMS[arm]
142
+ p0d, loop_game = cfg["p0"](task), cfg["loop"] / task
143
+ fin = loop_game / f"round_{FINAL_ROUND:02d}" / "score"
144
+ if not score_dir_ok(p0d):
145
+ return arm, task, None, "no round-0 score"
146
+ if not score_dir_ok(fin):
147
+ return arm, task, None, f"round {FINAL_ROUND} not scored (yet)"
148
+
149
+ b0 = json.loads((p0d / "p1" / "breakdown.json").read_text())
150
+ b1 = json.loads((fin / "p1" / "breakdown.json").read_text())
151
+ m0, m1 = per_demo_mean(b0), per_demo_mean(b1)
152
+ pads0, pads1 = pad_from(p0d), pad_from(fin)
153
+ cands = []
154
+ for demo in sorted(set(m0) & set(m1)):
155
+ v0, v1 = p0d / "p1" / "demos" / demo / f"{demo}.mp4", fin / "p1" / "demos" / demo / f"{demo}.mp4"
156
+ if not (v0.exists() and v1.exists()):
157
+ continue
158
+ usable = min(pads0.get(demo, 1e9), duration(v0), pads1.get(demo, 1e9), duration(v1), MAX_S)
159
+ if usable >= 4:
160
+ cands.append((m1[demo] - m0[demo], usable, demo, v0, v1))
161
+ if not cands:
162
+ return arm, task, None, "no common replayable demo"
163
+ gain, usable, demo, v0, v1 = max(cands)
164
+
165
+ out = MEDIA_OUT / "pairs" / arm / task
166
+ out.mkdir(parents=True, exist_ok=True)
167
+ for src, name in ((v0, "p0"), (v1, "final")):
168
+ if REUSE and (out / f"{name}.mp4").exists() and (out / f"{name}.webp").exists():
169
+ continue
170
+ subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", str(src), "-t", f"{usable:.2f}",
171
+ "-vf", "scale=854:480:force_original_aspect_ratio=decrease,pad=854:480:(ow-iw)/2:(oh-ih)/2,fps=24,format=yuv420p",
172
+ "-c:v", "libx264", "-preset", "slow", "-crf", "28", "-an", "-movflags", "+faststart",
173
+ str(out / f"{name}.mp4")], check=True)
174
+ png = subprocess.run(["ffmpeg", "-v", "error", "-ss", f"{usable * 0.45:.2f}", "-i", str(out / f"{name}.mp4"),
175
+ "-frames:v", "1", "-f", "image2pipe", "-c:v", "png", "-"], check=True, capture_output=True).stdout
176
+ Image.open(BytesIO(png)).convert("RGB").save(out / f"{name}.webp", "WEBP", quality=72)
177
+
178
+ s0, s1 = scores(p0d), scores(fin)
179
+ curve = [[0, s0["reward"]]]
180
+ for rd in sorted(loop_game.glob("round_*/score")):
181
+ r = int(rd.parent.name.split("_")[1])
182
+ if score_dir_ok(rd):
183
+ rw = json.loads((rd / "summary.json").read_text())["rewards"]
184
+ curve.append([r, round(sum(rw) / len(rw), 4)])
185
+
186
+ why0, why1 = rationales(p0d), rationales(fin)
187
+ reqs = []
188
+ for r in b1["requirements"]:
189
+ r0 = next((x for x in b0["requirements"] if x["id"] == r["id"]), None)
190
+ reqs.append({
191
+ "id": r["id"], "cat": CATS.get(r["id"][0], ""), "description": r["description"],
192
+ "p0": r0["aggregated"] if r0 else None, "final": r["aggregated"],
193
+ "p0_demo": r0["per_demo"].get(demo) if r0 else None, "final_demo": r["per_demo"].get(demo),
194
+ "why_p0": why0.get(r["id"], {}).get(demo, ""), "why_final": why1.get(r["id"], {}).get(demo, ""),
195
+ })
196
+ detail = {"demo": demo, "judge": b1.get("judge", {}).get("model"), "requirements": reqs, "rounds": timeline(loop_game)}
197
+ (PAGE / "static/data/evo" / arm).mkdir(parents=True, exist_ok=True)
198
+ (PAGE / "static/data/evo" / arm / f"{task}.json").write_text(json.dumps(detail, ensure_ascii=False))
199
+
200
+ return arm, task, {"s0": s0, "s1": s1, "demo": demo, "dur": round(usable, 1), "demo_gain": round(gain, 3), "curve": curve}, "ok"
201
+
202
+
203
+ def instruction_meta(task):
204
+ md = (TASKS / task / "instruction.md").read_text()
205
+ title = re.search(r"^# (.+)$", md, re.M).group(1).strip()
206
+ m = re.search(r"## Core Vision\s+(.+?)(?:\n## |\Z)", md, re.S)
207
+ toml = (TASKS / task / "task.toml").read_text()
208
+ d = re.search(r'^description\s*=\s*"(.*)"', toml, re.M)
209
+ return title, (d.group(1) if d else ""), re.sub(r"\s+", " ", m.group(1)).strip() if m else ""
210
+
211
+
212
+ def main():
213
+ ap = argparse.ArgumentParser()
214
+ ap.add_argument("--arms", nargs="+", default=list(ARMS))
215
+ ap.add_argument("--tasks", nargs="*")
216
+ ap.add_argument("--jobs", type=int, default=16)
217
+ ap.add_argument("--reuse-media", action="store_true", help="only rebuild data; keep encoded clips that exist")
218
+ a = ap.parse_args()
219
+
220
+ todo = [(arm, t.name) for arm in a.arms for t in sorted(ARMS[arm]["loop"].iterdir())
221
+ if t.is_dir() and (not a.tasks or t.name in a.tasks)]
222
+ rows, skipped = [], []
223
+ with ProcessPoolExecutor(a.jobs) as ex:
224
+ for f in [ex.submit(build_game, arm, task) for arm, task in todo]:
225
+ try:
226
+ arm, task, res, why = f.result()
227
+ except Exception as e: # one broken game must not sink the batch
228
+ print("FAIL", type(e).__name__, str(e)[-300:], file=sys.stderr, flush=True)
229
+ continue
230
+ if res is None:
231
+ skipped.append((arm, task, why))
232
+ continue
233
+ title, blurb, vision = instruction_meta(task)
234
+ cfg = ARMS[arm]
235
+ s0, s1 = res["s0"], res["s1"]
236
+ rows.append({
237
+ "arm": arm, "task": task, "engine": cfg["engine"], "model": cfg["model"], "harness": cfg["harness"],
238
+ "genre": task.split("-")[0], "title": title, "blurb": blurb, "vision": vision,
239
+ "reward": s0["reward"], "build": s0["build"], **{k: s0[k] for k in CATS},
240
+ "pair": f"pairs/{arm}/{task}", "demo": res["demo"], "dur": res["dur"], "demo_gain": res["demo_gain"],
241
+ "repaired": {"round": FINAL_ROUND, "reward": s1["reward"], "build": s1["build"], **{k: s1[k] for k in CATS}},
242
+ "curve": res["curve"], "media": None, "demos": [],
243
+ })
244
+ print("ok", arm, task, f"{s0['reward']:.3f} -> {s1['reward']:.3f}", res["demo"], flush=True)
245
+ rows.sort(key=lambda g: (g["arm"], -(g["repaired"]["reward"] - g["reward"])))
246
+ (PAGE / "static/data/evo.json").write_text(json.dumps(rows, ensure_ascii=False, indent=0))
247
+ print(f"wrote {len(rows)} pairs; skipped {len(skipped)}:")
248
+ for s in skipped:
249
+ print(" skip", *s)
250
+
251
+
252
+ if __name__ == "__main__":
253
+ main()
_build/build_web.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Playable builds: export a game's round-0 and round-30 Godot projects to the Web (single-threaded WASM).
2
+
3
+ All exports share one engine (same 4.6.2 template), so it is stored once, gzipped:
4
+ MEDIA_OUT/play/engine/godot.js, godot.wasm.gz, godot.audio.worklet.js, godot.audio.position.worklet.js
5
+ and each build only ships its game data:
6
+ MEDIA_OUT/play/<arm>/<task>/<r0|r30>/index.pck
7
+ plus static/data/play.json for the page (sizes feed the loading bar).
8
+
9
+ Needs Godot 4.6.2 with web export templates in ~/.local/share/godot/export_templates/4.6.2.stable.
10
+
11
+ python _build/build_web.py [--games godot_gpt/platformer-echo-climb ...]
12
+ """
13
+ import argparse, gzip, json, shutil, subprocess, sys
14
+ from pathlib import Path
15
+
16
+ PAGE = Path(__file__).resolve().parents[1]
17
+ MEDIA_OUT = Path("/storage/admin/wenyi/evogame-page-media")
18
+ WORK = Path("/storage/admin/wenyi/evogame-web/work")
19
+ PRESET = Path(__file__).with_name("web_export_presets.cfg")
20
+ SOURCES = {
21
+ "godot_gpt": {"r0": lambda t: Path(f"/storage/admin/wenyi/r0_trees/godot_gpt/{t}/game"),
22
+ "r30": lambda t: Path(f"/storage/admin/wenyi/newloop_self40/runs/{t}/round_30/tree")},
23
+ "godot_qwen": {"r0": lambda t: Path(f"/home/admin/wenyi/gamedoctor_run/godot/corpus_qwen/{t}"),
24
+ "r30": lambda t: Path(f"/home/admin/wenyi/gamedoctor_run/newloop_selfqwen40/runs/{t}/round_30/tree")},
25
+ }
26
+ # what a visitor needs to know to play; read off each game's own help text
27
+ CONTROLS = {
28
+ "godot_gpt/platformer-echo-climb": "A / D move · Space jump · R restart",
29
+ "godot_qwen/shooter-void-patrol": "WASD / arrows move · hold Space fire · X bomb",
30
+ "godot_gpt/shooter-sky-duel": "arrows fly · Space fire",
31
+ "godot_qwen/platformer-dig-descent": "Enter start · A / D steer · Space shoot down",
32
+ }
33
+
34
+
35
+ def export(src, work, out):
36
+ shutil.rmtree(work, ignore_errors=True)
37
+ work.parent.mkdir(parents=True, exist_ok=True)
38
+ subprocess.run(["rsync", "-a", "--exclude", "*.py", "--exclude", ".godot", f"{src}/", f"{work}/"], check=True)
39
+ shutil.copy(PRESET, work / "export_presets.cfg")
40
+ out.mkdir(parents=True, exist_ok=True)
41
+ log = work.with_suffix(".log").open("w")
42
+ subprocess.run(["godot", "--headless", "--path", str(work), "--import"], stdout=log, stderr=subprocess.STDOUT, timeout=900)
43
+ subprocess.run(["godot", "--headless", "--path", str(work), "--export-release", "Web", str(out / "index.html")],
44
+ stdout=log, stderr=subprocess.STDOUT, timeout=900, check=True)
45
+ return out
46
+
47
+
48
+ def main():
49
+ ap = argparse.ArgumentParser()
50
+ ap.add_argument("--games", nargs="+", default=list(CONTROLS))
51
+ a = ap.parse_args()
52
+ evo = {f"{g['arm']}/{g['task']}": g for g in json.loads((PAGE / "static/data/evo.json").read_text())}
53
+ engine = MEDIA_OUT / "play" / "engine"
54
+ rows = []
55
+ for key in a.games:
56
+ arm, task = key.split("/")
57
+ g = evo.get(key)
58
+ builds = {}
59
+ for rnd in ("r0", "r30"):
60
+ out = export(SOURCES[arm][rnd](task), WORK / task / rnd, MEDIA_OUT / "play" / arm / task / rnd)
61
+ if not (engine / "godot.wasm.gz").exists():
62
+ engine.mkdir(parents=True, exist_ok=True)
63
+ shutil.copy(out / "index.js", engine / "godot.js")
64
+ for w in ("audio.worklet.js", "audio.position.worklet.js"):
65
+ shutil.copy(out / f"index.{w}", engine / f"godot.{w}")
66
+ raw = (out / "index.wasm").read_bytes()
67
+ (engine / "godot.wasm.gz").write_bytes(gzip.compress(raw, 9))
68
+ (engine / "sizes.json").write_text(json.dumps({"wasm": len(raw), "wasm_gz": (engine / "godot.wasm.gz").stat().st_size}))
69
+ # keep only what the shared player needs
70
+ for f in out.iterdir():
71
+ if f.name != "index.pck":
72
+ f.unlink()
73
+ builds[rnd] = {"pck": f"play/{arm}/{task}/{rnd}/index.pck", "size": (out / "index.pck").stat().st_size}
74
+ print("exported", key, rnd, builds[rnd]["size"] // 1024, "KB", flush=True)
75
+ rows.append({
76
+ "arm": arm, "task": task, "title": g["title"] if g else task, "model": g["model"] if g else "",
77
+ "controls": CONTROLS.get(key, ""), "pair": g["pair"] if g else None,
78
+ "scores": {"r0": g["reward"], "r30": g["repaired"]["reward"]} if g else {},
79
+ "builds": builds,
80
+ })
81
+ old = json.loads((PAGE / "static/data/play.json").read_text()).get("games", []) if (PAGE / "static/data/play.json").exists() else []
82
+ rows += [r for r in old if f"{r['arm']}/{r['task']}" not in a.games]
83
+ sizes = json.loads((engine / "sizes.json").read_text())
84
+ (PAGE / "static/data/play.json").write_text(json.dumps({"engine": {"base": "play/engine/godot", **sizes}, "games": rows}, indent=1))
85
+ print("wrote static/data/play.json")
86
+
87
+
88
+ if __name__ == "__main__":
89
+ sys.exit(main())
_build/web_export_presets.cfg ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [preset.0]
2
+
3
+ name="Web"
4
+ platform="Web"
5
+ runnable=true
6
+ dedicated_server=false
7
+ custom_features=""
8
+ export_filter="all_resources"
9
+ include_filter="*.json"
10
+ exclude_filter="*.py, demo_outputs/*"
11
+ export_path="build/index.html"
12
+ patches=PackedStringArray()
13
+ encryption_include_filters=""
14
+ encryption_exclude_filters=""
15
+ seed=0
16
+ encrypt_pck=false
17
+ encrypt_directory=false
18
+ script_export_mode=2
19
+
20
+ [preset.0.options]
21
+
22
+ custom_template/debug=""
23
+ custom_template/release=""
24
+ variant/extensions_support=false
25
+ variant/thread_support=false
26
+ vram_texture_compression/for_desktop=true
27
+ vram_texture_compression/for_mobile=false
28
+ html/export_icon=true
29
+ html/custom_html_shell=""
30
+ html/head_include=""
31
+ html/canvas_resize_policy=2
32
+ html/focus_canvas_on_start=true
33
+ html/experimental_virtual_keyboard=false
34
+ progressive_web_app/enabled=false
index.html CHANGED
@@ -3,7 +3,7 @@
3
  <head>
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>EvoGame</title>
7
  <meta name="description" content="EvoGame turns open-ended game improvement into goal-oriented, state-controlled progressive development through agentic verification.">
8
  <link rel="preconnect" href="https://fonts.googleapis.com">
9
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -139,7 +139,6 @@
139
 
140
  <footer class="footer"><div class="wrap">EvoGame project page · demo media hosted on Hugging Face</div></footer>
141
 
142
- <script>window.EVO_MEDIA = "media"; window.EVO_NO_SWITCH = true;</script>
143
  <script src="static/js/core.js"></script>
144
  <script src="static/js/v1.js"></script>
145
  </body>
 
3
  <head>
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>EvoGame: Self-Evolving Game Generation</title>
7
  <meta name="description" content="EvoGame turns open-ended game improvement into goal-oriented, state-controlled progressive development through agentic verification.">
8
  <link rel="preconnect" href="https://fonts.googleapis.com">
9
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
 
139
 
140
  <footer class="footer"><div class="wrap">EvoGame project page · demo media hosted on Hugging Face</div></footer>
141
 
 
142
  <script src="static/js/core.js"></script>
143
  <script src="static/js/v1.js"></script>
144
  </body>
static/css/v2.css ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* v2 — "paper page": light, academic, serif display. Loaded after base.css. */
2
+ :root {
3
+ color-scheme: light;
4
+ --bg: #fbfaf7;
5
+ --bg-alt: #f3f1ea;
6
+ --surface: #ffffff;
7
+ --surface-2: #f6f5f0;
8
+ --border: #e3e0d6;
9
+ --text: #1b1c20;
10
+ --text-2: #46474f;
11
+ --muted: #6f707a;
12
+ --accent: #0f6f66; /* deep teal */
13
+ --accent-soft: rgba(15, 111, 102, .08);
14
+ --accent-ink: #ffffff;
15
+ --godot: #2a78d6; /* series 1 (light step) */
16
+ --phaser: #eb6834; /* series 2 (light step) */
17
+ --bar: #0f6f66;
18
+ --track: #e8e5dc;
19
+ --hover-border: #c9c5b8;
20
+ --nav-bg: rgba(251, 250, 247, .8);
21
+ --nav-bg-solid: rgba(251, 250, 247, .95);
22
+ --chip-bg: rgba(20, 21, 26, .72);
23
+ --overlay: rgba(28, 27, 24, .5);
24
+ --stripe-a: #f1efe8;
25
+ --stripe-b: #e9e6dd;
26
+ --media-bg: #16171b;
27
+ --s-hi: #1e7a3c; --s-mid: #9a6100; --s-lo: #b8382a;
28
+ --radius: 10px;
29
+ --display: "Newsreader", "Iowan Old Style", "Palatino Linotype", Georgia, serif;
30
+ --font: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
31
+ --mono: "JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;
32
+ }
33
+
34
+ body { font-size: 16px; line-height: 1.65; }
35
+ h1, h2, h3 { font-weight: 500; letter-spacing: -0.005em; }
36
+ .wrap.narrow { max-width: 1040px; }
37
+ .wrap.wide { max-width: 1360px; }
38
+ em { font-style: italic; }
39
+
40
+ /* ---------- nav ---------- */
41
+ .nav { height: 54px; border-bottom: 1px solid var(--border); background: var(--nav-bg-solid); }
42
+ .brand { font: 600 20px var(--display); letter-spacing: 0; }
43
+ .nav-links a { color: var(--text-2); font-size: 14px; }
44
+ .nav-links a:hover { background: var(--surface-2); }
45
+ .nav-links { min-width: 0; }
46
+ @media (max-width: 720px) { .nav .nav-links a[href="#compare"] { display: none; } }
47
+ html { scroll-padding-top: 64px; }
48
+
49
+ /* ---------- title block ---------- */
50
+ .title-block { padding-block: 116px 0; text-align: center; }
51
+ .kicker { font: 600 12px var(--font); letter-spacing: .14em; text-transform: uppercase; color: var(--accent); margin: 0 0 14px; }
52
+ .title-block h1 { font: 500 clamp(56px, 9vw, 104px)/1 var(--display); letter-spacing: -0.02em; }
53
+ .paper-title { font: 400 clamp(21px, 2.7vw, 30px)/1.3 var(--display); color: var(--text); max-width: 820px; margin: 16px auto 22px; text-wrap: balance; }
54
+ .authors { font-size: 16px; color: var(--text-2); margin: 0; }
55
+ .affil { font-size: 14px; color: var(--muted); margin: 2px 0 26px; }
56
+ .links { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; }
57
+ .link-pill { display: inline-flex; align-items: center; gap: 8px; padding: 8px 16px; border-radius: 999px; background: #202127; color: #fff; text-decoration: none; font-size: 14px; font-weight: 500; transition: background .15s; }
58
+ .link-pill svg { width: 15px; height: 15px; flex: none; }
59
+ .link-pill small { font: 500 10px var(--mono); text-transform: uppercase; letter-spacing: .06em; color: #a9abb5; }
60
+ .link-pill[aria-disabled="true"] { cursor: default; background: #4a4b52; }
61
+ .link-pill.solid { background: var(--accent); }
62
+ .link-pill.solid:hover { background: #0b5a53; }
63
+
64
+ .teaser { margin: 48px auto 0; max-width: 1180px; }
65
+ .teaser-frame { position: relative; aspect-ratio: 32 / 9; max-width: 100%; border-radius: 12px; overflow: hidden; background: var(--media-bg); box-shadow: 0 1px 2px rgba(0,0,0,.06), 0 12px 40px rgba(40, 36, 20, .12); }
66
+ .teaser-frame video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
67
+ @media (max-width: 700px) { .teaser-frame { aspect-ratio: 16 / 9; } }
68
+ .teaser figcaption, .fig figcaption, .diagram figcaption { font-size: 14px; color: var(--text-2); margin-top: 12px; text-align: left; max-width: 860px; }
69
+ .teaser figcaption { margin-inline: auto; text-align: center; }
70
+ figcaption b { color: var(--text); font-weight: 600; }
71
+
72
+ /* ---------- sections ---------- */
73
+ .section { padding-block: clamp(56px, 7vw, 88px); }
74
+ .section.alt { background: var(--bg-alt); border-block: 1px solid var(--border); }
75
+ .h-section { font: 500 clamp(30px, 3.6vw, 40px)/1.15 var(--display); margin-bottom: 14px; }
76
+ .lede { color: var(--text-2); margin: 0 0 28px; max-width: 760px; }
77
+ .narrow-head { max-width: 1040px; margin: 0 auto; }
78
+ .muted { color: var(--muted); }
79
+
80
+ .tldr { display: flex; gap: 18px; align-items: baseline; background: var(--surface); border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: 0 var(--radius) var(--radius) 0; padding: 18px 22px; margin-bottom: 56px; }
81
+ .tldr-tag { font: 700 12px var(--mono); color: var(--accent); flex: none; }
82
+ .tldr p { margin: 0; font: 400 19px/1.5 var(--display); color: var(--text); }
83
+ @media (max-width: 600px) { .tldr { flex-direction: column; gap: 6px; } }
84
+
85
+ .abstract { display: grid; grid-template-columns: 1fr 250px; gap: 44px; align-items: start; }
86
+ .prose p { margin: 0 0 16px; color: var(--text-2); text-align: justify; hyphens: auto; }
87
+ .prose b { color: var(--text); font-weight: 600; }
88
+ .facts { margin: 0; display: grid; gap: 0; border-top: 1px solid var(--border); }
89
+ .facts div { padding: 14px 0; border-bottom: 1px solid var(--border); }
90
+ .facts dt { font: 500 36px/1 var(--display); color: var(--accent); }
91
+ .facts dd { margin: 4px 0 0; font-size: 14px; color: var(--text-2); }
92
+ @media (max-width: 820px) {
93
+ .abstract { grid-template-columns: 1fr; gap: 20px; }
94
+ .facts { grid-template-columns: repeat(2, 1fr); column-gap: 20px; }
95
+ .prose p { text-align: left; }
96
+ }
97
+
98
+ /* ---------- method diagram ---------- */
99
+ .diagram { margin: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 26px 26px 18px; }
100
+ .dg-row { display: grid; grid-template-columns: 132px 1fr; gap: 18px; align-items: center; padding-block: 16px; border-bottom: 1px dashed var(--border); }
101
+ .dg-row:first-child { padding-top: 0; }
102
+ .dg-row:nth-last-child(2) { border-bottom: 0; }
103
+ .dg-label { font: 600 11px var(--mono); text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
104
+ .dg-states, .dg-loop { list-style: none; margin: 0; padding: 0; display: flex; align-items: stretch; gap: 0; }
105
+ .dg-states li:not(.arrow) { flex: 1; background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px; }
106
+ .dg-states b, .dg-loop b { display: block; font: 600 17px var(--display); color: var(--text); }
107
+ .dg-states span, .dg-loop span { display: block; font-size: 13px; line-height: 1.4; color: var(--text-2); margin-top: 2px; }
108
+ .dg-states li:nth-child(1) { border-top: 3px solid #9fc9c3; }
109
+ .dg-states li:nth-child(3) { border-top: 3px solid #4f9d94; }
110
+ .dg-states li:nth-child(5) { border-top: 3px solid var(--accent); }
111
+ .dg-exp li:not(.arrow) { border-top: 1px solid var(--border) !important; background: var(--surface-2); }
112
+ .arrow { flex: 0 0 28px; position: relative; }
113
+ .arrow::before { content: ""; position: absolute; left: 5px; right: 9px; top: 50%; height: 1.5px; background: var(--muted); }
114
+ .arrow::after { content: ""; position: absolute; right: 5px; top: calc(50% - 4px); border: 4.5px solid transparent; border-left: 6px solid var(--muted); border-right: 0; }
115
+ .dg-loop { gap: 10px; counter-reset: step; }
116
+ .dg-loop li { flex: 1; position: relative; padding: 10px 14px 10px 14px; border-radius: 8px; background: var(--bg); border: 1px solid var(--border); }
117
+ .dg-loop li.verify { background: var(--accent-soft); border-color: rgba(15, 111, 102, .35); }
118
+ .dg-loop li.verify b { color: var(--accent); }
119
+ .dg-loop i { font: 600 11px var(--mono); font-style: normal; color: var(--muted); }
120
+ .dg-loop li:not(:last-child)::after { content: "→"; position: absolute; right: -10px; top: 50%; transform: translate(50%, -50%); color: var(--muted); font-size: 13px; background: var(--surface); line-height: 1; padding: 1px 0; z-index: 1; }
121
+ .dg-return { grid-column: 2; margin: 8px 0 0; font-size: 13px; color: var(--accent); }
122
+ .diagram figcaption { margin-top: 10px; }
123
+ @media (max-width: 820px) {
124
+ .dg-row { grid-template-columns: 1fr; gap: 10px; }
125
+ .dg-return { grid-column: 1; }
126
+ .dg-states, .dg-loop { flex-direction: column; }
127
+ .arrow { flex: 0 0 22px; }
128
+ .arrow::before { left: 50%; right: auto; top: 3px; bottom: 9px; width: 1.5px; height: auto; }
129
+ .arrow::after { left: calc(50% - 4px); right: auto; top: auto; bottom: 3px; border: 4.5px solid transparent; border-top: 6px solid var(--muted); border-bottom: 0; }
130
+ .dg-loop li:not(:last-child)::after { content: "↓"; right: 50%; top: auto; bottom: -10px; transform: translate(50%, 50%); padding: 0 4px; }
131
+ .diagram { padding: 18px 16px 14px; }
132
+ }
133
+
134
+ /* ---------- before / after ---------- */
135
+ .compare { border-radius: 12px; padding: 18px 18px 20px; }
136
+ .compare-head h3 { font: 500 23px var(--display); }
137
+ .compare-head button:hover h3 { color: var(--accent); }
138
+ .compare-label { font: 600 11px var(--font); letter-spacing: .08em; }
139
+ .compare-label .after { color: var(--accent); }
140
+ .compare-label b { font-family: var(--mono); font-size: 14px; }
141
+ .pill { font-family: var(--font); border-radius: 999px; background: var(--surface-2); }
142
+ .frame { border-radius: 8px; }
143
+ .pending { color: var(--muted); }
144
+ .pending b { font: 500 18px var(--display); color: var(--text-2); }
145
+
146
+ /* ---------- gallery ---------- */
147
+ .filters { top: 54px; background: var(--bg-alt); padding-block: 14px; margin-bottom: 20px; border-bottom: 1px solid var(--border); }
148
+ .seg { border-radius: 999px; padding: 3px; }
149
+ .seg button { border-radius: 999px; padding: 5px 14px; }
150
+ .seg button.on { background: var(--text); color: #fff; box-shadow: none; }
151
+ .filters select, .filters input { border-radius: 999px; padding: 7px 14px; }
152
+ .filters select:focus, .filters input:focus { outline: 2px solid rgba(15, 111, 102, .45); }
153
+ .count { font-family: var(--font); }
154
+
155
+ .grid.dense { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 22px 16px; }
156
+ .card.v2 { min-width: 0; }
157
+ .card.v2 { background: transparent; border: 0; border-radius: 0; overflow: visible; box-shadow: none; }
158
+ .card.v2:hover, .card.v2:focus-visible { transform: none; box-shadow: none; }
159
+ .card.v2 .frame { border-radius: 8px; border: 1px solid var(--border); transition: box-shadow .18s, transform .18s; }
160
+ .card.v2:hover .frame, .card.v2:focus-visible .frame { box-shadow: 0 10px 28px rgba(40, 36, 20, .18); transform: translateY(-2px); }
161
+ .card.v2:focus-visible .frame { outline: 2px solid var(--accent); outline-offset: 2px; }
162
+ .card.v2 .c-body { padding: 9px 2px 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: 2px; min-width: 0; }
163
+ .c-top { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
164
+ .c-title { font: 500 17px/1.25 var(--display); color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
165
+ .c-score { font: 600 14px var(--mono); color: var(--text); flex: none; }
166
+ .c-meta { display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--muted); }
167
+ .c-cats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-top: 7px; }
168
+ .c-cats span { display: flex; gap: 4px; font: 500 10px var(--mono); color: var(--muted); border-top: 2px solid var(--track); padding-top: 4px; position: relative; }
169
+ .c-cats span i { position: absolute; left: 0; top: -2px; height: 2px; background: var(--bar); }
170
+ .c-cats span b { font-weight: 500; color: var(--text-2); }
171
+ .more .btn { border-radius: 999px; padding: 9px 22px; }
172
+ @media (max-width: 520px) { .grid.dense { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 10px; } .c-cats { display: none; } .c-title { font-size: 15px; } }
173
+
174
+ /* ---------- results ---------- */
175
+ .formula { font: 500 14px var(--mono); color: var(--text); background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 12px 16px; margin: -12px 0 28px; overflow-x: auto; white-space: nowrap; }
176
+ .fig { margin: 0 0 28px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 18px 20px; min-width: 0; }
177
+ .fig-head { display: flex; justify-content: flex-end; }
178
+ .legend { margin: 0 0 8px; }
179
+ .fig-pair { display: grid; grid-template-columns: 1.15fr .85fr; gap: 20px; margin-bottom: 28px; }
180
+ .fig-pair .fig { margin: 0; }
181
+ .fig-note { padding: 6px 4px; }
182
+ .fig-note h3 { font: 500 20px var(--display); margin-bottom: 10px; }
183
+ .fig-note p { margin: 0 0 8px; font-size: 14px; color: var(--text-2); }
184
+ .fig-note b { color: var(--text); font-weight: 600; }
185
+ @media (max-width: 820px) { .fig-pair { grid-template-columns: 1fr; } }
186
+ .table-fig { padding: 0; overflow: hidden; }
187
+ .table-fig figcaption { margin: 0; padding: 14px 20px; border-bottom: 1px solid var(--border); max-width: none; }
188
+ .table-fig .table-wrap { margin: 0; border: 0; border-radius: 0; }
189
+ .table { font-size: 14px; }
190
+ .table th { font: 600 11px var(--font); letter-spacing: .06em; background: var(--surface-2); }
191
+ .row-label { font-family: var(--font); }
192
+ .strip-dot { stroke: #fff; }
193
+ .tip { background: #1b1c20; border-color: #1b1c20; }
194
+
195
+ /* ---------- citation / footer ---------- */
196
+ .bibtex { background: var(--surface); color: var(--text); }
197
+ .copy { background: var(--surface-2); }
198
+ .footer { background: var(--bg); }
199
+
200
+ /* ---------- modal ---------- */
201
+ .modal-panel { background: var(--surface); border-color: var(--border); border-radius: 14px; box-shadow: 0 30px 80px rgba(20, 18, 10, .3); }
202
+ .modal-media { background: var(--bg); }
203
+ .modal-close { background: var(--surface); color: var(--text); }
204
+ .player { border-radius: 8px; }
205
+ .demo-tabs button { border-radius: 999px; background: var(--surface); }
206
+ .demo-tabs button.on { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); }
207
+ .demo-tabs button.on small { color: var(--accent); }
208
+ .m-prompt h3 { font-family: var(--font); }
209
+ .m-prompt p { font: 400 16px/1.55 var(--display); color: var(--text-2); }
210
+ .modal-info h2 { font: 500 32px/1.15 var(--display); }
211
+ .m-score { background: var(--bg); }
212
+ .m-overall b { font-weight: 600; }
213
+ .m-overall span, .req-cat { font-family: var(--font); }
214
+ .twin { background: var(--surface); }
215
+ .twin:hover { border-color: var(--accent); }
216
+ .m-h { font: 500 21px var(--display); }
217
+ .req { background: var(--surface); }
218
+ .req.open { border-color: var(--hover-border); }
219
+ .req-demos button { background: var(--surface-2); }
220
+ .req-demos button.on { border-color: var(--accent); }
221
+ .rationale { background: var(--accent-soft); border-left-color: var(--accent); color: var(--text); }
222
+ .btn { border-radius: 999px; }
223
+
224
+ .brand, .h-section, .title-block h1 { color: var(--text); }
static/css/v3.css ADDED
@@ -0,0 +1,269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* v3 — Arcade. Layered on base.css: tokens first, then arcade-specific pieces. */
2
+ :root {
3
+ --bg: #0d0818;
4
+ --bg-alt: #120b22;
5
+ --surface: #1a1030;
6
+ --surface-2: #241640;
7
+ --border: #3a2764;
8
+ --text: #f4f0ff;
9
+ --text-2: #cbc1e8;
10
+ --muted: #9387b6;
11
+ --accent: #ffd23f; /* arcade yellow */
12
+ --accent-ink: #1a1030;
13
+ --cyan: #3ef2ff;
14
+ --magenta: #ff4fd8;
15
+ --bar: #ffd23f;
16
+ --track: #2d1f4d;
17
+ --hover-border: #6d52b8;
18
+ --nav-bg: rgba(13, 8, 24, .7);
19
+ --nav-bg-solid: rgba(13, 8, 24, .94);
20
+ --chip-bg: rgba(13, 8, 24, .88);
21
+ --overlay: rgba(6, 3, 14, .88);
22
+ --stripe-a: #150d28;
23
+ --stripe-b: #1b1233;
24
+ --media-bg: #05030b;
25
+ --s-hi: #7cf29a; --s-mid: #ffd23f; --s-lo: #ff7a8a;
26
+ --radius: 0px;
27
+ --pixel: "Press Start 2P", ui-monospace, "SFMono-Regular", Menlo, monospace;
28
+ --display: var(--pixel);
29
+ --font: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
30
+ --shadow: 4px 4px 0 #000;
31
+ }
32
+
33
+ body.arcade { background:
34
+ radial-gradient(1200px 600px at 80% -10%, rgba(255, 79, 216, .10), transparent 60%),
35
+ radial-gradient(900px 500px at 0% 20%, rgba(62, 242, 255, .07), transparent 60%),
36
+ var(--bg); }
37
+
38
+ /* pixel font only where it's a label, heading or number */
39
+ h1, h2, .level h3, .chart-card h3, .m-h { font-family: var(--pixel); font-weight: 400; letter-spacing: 0; }
40
+ h3 { font-family: var(--font); font-weight: 700; letter-spacing: -0.01em; }
41
+
42
+ .scanlines {
43
+ position: fixed; inset: 0; z-index: 90; pointer-events: none;
44
+ background: repeating-linear-gradient(to bottom, rgba(0, 0, 0, .22) 0 1px, transparent 1px 3px);
45
+ opacity: .35; mix-blend-mode: multiply;
46
+ }
47
+
48
+ /* ---------- nav ---------- */
49
+ .nav { height: 60px; border-bottom: 3px solid transparent; }
50
+ .nav.scrolled { border-color: var(--border); }
51
+ .brand { font: 400 13px var(--pixel); gap: 10px; }
52
+ .coin { width: 14px; height: 14px; background: var(--accent); box-shadow: inset -3px -3px 0 #c79a12, 0 0 0 2px #000; }
53
+ .nav-links a { font: 400 10px var(--pixel); color: var(--text-2); border-radius: 0; padding: 8px 10px; }
54
+ .nav-links a:hover { background: var(--surface); color: var(--accent); }
55
+ @media (max-width: 720px) { .nav-links a:not([href="#gallery"]):not([href="#compare"]) { display: none; } }
56
+
57
+ /* ---------- buttons ---------- */
58
+ .btn { font: 400 10px/1 var(--pixel); border-radius: 0; border: 3px solid var(--border); background: var(--surface);
59
+ box-shadow: var(--shadow); padding: 12px 13px; transition: transform .08s, box-shadow .08s, border-color .15s; }
60
+ .btn:hover { border-color: var(--cyan); background: var(--surface-2); }
61
+ .btn:active { transform: translate(2px, 2px); box-shadow: 2px 2px 0 #000; }
62
+ .btn small { font: 400 7px var(--pixel); color: var(--magenta); letter-spacing: 0; }
63
+ .btn-start { background: var(--accent); color: var(--accent-ink); border-color: #000; }
64
+ .btn-start:hover { background: #ffe06e; border-color: #000; box-shadow: 4px 4px 0 var(--magenta); }
65
+ .btn-sm { padding: 8px 10px; font-size: 9px; }
66
+ .tri { display: inline-block; width: 8px; height: 10px; background: currentColor; clip-path: polygon(0 0, 100% 50%, 0 100%); margin-right: 10px; vertical-align: -1px; }
67
+ .blink { animation: blink 1s steps(2, start) infinite; }
68
+ @keyframes blink { to { visibility: hidden; } }
69
+
70
+ /* ---------- hero ---------- */
71
+ .hero { min-height: auto; display: block; padding-block: 108px 56px; overflow: visible; }
72
+ .hero-grid { max-width: 1240px; margin: 0 auto; padding-inline: var(--gutter); display: grid; grid-template-columns: 1.05fr 1fr; gap: clamp(28px, 5vw, 64px); align-items: center; }
73
+ .insert-coin { font: 400 10px/1.6 var(--pixel); color: var(--cyan); margin: 0 0 22px; }
74
+ .hero h1 { font-size: clamp(38px, 7.2vw, 84px); line-height: 1.05; letter-spacing: 0; text-shadow: none; display: flex; flex-wrap: wrap; }
75
+ .h1-evo { color: var(--cyan); text-shadow: 4px 4px 0 #0b3e52, 8px 8px 0 #000; }
76
+ .h1-game { color: var(--accent); text-shadow: 4px 4px 0 #9b2a86, 8px 8px 0 #000; }
77
+ .hero-sub { font-size: clamp(16px, 1.9vw, 20px); color: var(--text); margin: 26px 0 8px; max-width: 560px; text-shadow: none; }
78
+ .authors { color: var(--muted); margin: 0 0 28px; }
79
+ .hero-cta { justify-content: flex-start; gap: 12px; }
80
+
81
+ .cabinet { position: relative; background: linear-gradient(180deg, #2a1650, #1a0e33); border: 3px solid #000;
82
+ box-shadow: 0 0 0 3px var(--border), 10px 10px 0 #000; padding: 14px 16px 16px; }
83
+ .marquee { font: 400 clamp(9px, 1.1vw, 12px)/1 var(--pixel); text-align: center; color: #fff; padding: 12px 8px; margin-bottom: 14px;
84
+ background: linear-gradient(90deg, var(--magenta), #7a3cff 50%, var(--cyan)); border: 3px solid #000;
85
+ text-shadow: 2px 2px 0 #000; letter-spacing: .08em; }
86
+ .bezel { background: #07040f; border: 3px solid #000; padding: clamp(10px, 1.6vw, 18px); box-shadow: inset 0 0 0 3px #1f1340; }
87
+ .crt { position: relative; aspect-ratio: 16 / 9; max-width: 100%; overflow: hidden; border-radius: 14px / 18px; background: var(--media-bg);
88
+ box-shadow: inset 0 0 40px rgba(0, 0, 0, .9); }
89
+ .crt video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; filter: saturate(1.1) contrast(1.05); }
90
+ .crt-glare { position: absolute; inset: 0; pointer-events: none;
91
+ background: radial-gradient(ellipse at 30% 20%, rgba(255, 255, 255, .10), transparent 45%),
92
+ repeating-linear-gradient(to bottom, rgba(0, 0, 0, .25) 0 1px, transparent 1px 3px);
93
+ box-shadow: inset 0 0 60px rgba(0, 0, 0, .75); }
94
+ .controls { display: flex; align-items: center; gap: 14px; padding: 16px 10px 2px; }
95
+ .stick { width: 16px; height: 16px; border-radius: 50%; background: #e23; box-shadow: 0 0 0 3px #000, 0 10px 0 -4px #111; margin-right: auto; }
96
+ .btn-dot { width: 18px; height: 18px; border-radius: 50%; box-shadow: 0 0 0 3px #000, inset -3px -3px 0 rgba(0, 0, 0, .3); }
97
+ .b1 { background: var(--cyan); } .b2 { background: var(--accent); } .b3 { background: var(--magenta); }
98
+ @media (max-width: 900px) {
99
+ .hero { padding-top: 88px; }
100
+ .hero-grid { grid-template-columns: 1fr; }
101
+ .cabinet { order: -1; }
102
+ }
103
+
104
+ /* ---------- hi-score ---------- */
105
+ .hiscore-section { padding-block: 24px 40px; }
106
+ .hiscore { max-width: 820px; margin: 0 auto; border: 3px solid var(--cyan); background: rgba(10, 6, 20, .85);
107
+ box-shadow: 0 0 0 3px #000, 8px 8px 0 #000, 0 0 40px rgba(62, 242, 255, .15); padding: clamp(18px, 3vw, 30px); }
108
+ .hiscore-title { font: 400 clamp(12px, 2vw, 16px) var(--pixel); color: var(--accent); text-align: center; margin-bottom: 20px; }
109
+ .star { font: 700 18px var(--font); color: var(--magenta); vertical-align: 1px; }
110
+ .hiscore-rows { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; }
111
+ .hiscore-rows li { display: grid; grid-template-columns: 60px 1fr auto; align-items: baseline; gap: 14px; }
112
+ .hiscore-rows .rank { font: 400 11px var(--pixel); color: var(--magenta); }
113
+ .hiscore-rows li:nth-child(1) .rank { color: var(--accent); }
114
+ .hiscore-rows li:nth-child(2) .rank { color: #d7deea; }
115
+ .hiscore-rows li:nth-child(3) .rank { color: #e59a62; }
116
+ .hiscore-rows .what { color: var(--text-2); font-size: 15px; border-bottom: 2px dotted var(--border); padding-bottom: 4px; min-width: 0; }
117
+ .hiscore-rows b { font: 400 clamp(16px, 2.6vw, 24px) var(--pixel); color: var(--text); }
118
+ @media (max-width: 520px) {
119
+ .hiscore-rows li { grid-template-columns: 44px 1fr auto; gap: 10px; }
120
+ .hiscore-rows .rank { font-size: 9px; }
121
+ .hiscore-rows .what { font-size: 13px; }
122
+ }
123
+
124
+ /* ---------- sections ---------- */
125
+ .section-alt { background: var(--bg-alt); border-block: 3px solid var(--border); }
126
+ .section-head { max-width: 820px; }
127
+ .section-head h2 { font-size: clamp(18px, 3vw, 30px); line-height: 1.45; margin-bottom: 16px; }
128
+ .section-head p + p { margin-top: 12px; }
129
+ .section-head strong { color: var(--accent); }
130
+ .kicker { font: 400 10px var(--pixel) !important; color: var(--magenta) !important; margin: 0 0 14px !important; }
131
+
132
+ /* ---------- level select ---------- */
133
+ .level-select { list-style: none; margin: 8px 0 0; padding: 0; display: grid; grid-template-columns: 1fr auto 1fr auto 1fr; gap: 14px; align-items: stretch; }
134
+ .level { position: relative; background: var(--surface); border: 3px solid var(--border); box-shadow: var(--shadow); padding: 20px 18px 16px; }
135
+ .level-tag { display: inline-block; font: 400 10px var(--pixel); background: var(--cyan); color: #04121a; padding: 6px 8px; margin-bottom: 14px; box-shadow: 3px 3px 0 #000; }
136
+ .level h3 { font-size: 15px; color: var(--text); margin-bottom: 10px; }
137
+ .level p { margin: 0 0 12px; color: var(--text-2); font-size: 14px; }
138
+ .level-stars { font-size: 18px; color: var(--accent); letter-spacing: 4px; }
139
+ .level-boss { border-color: var(--accent); }
140
+ .level-boss .level-tag { background: var(--accent); color: var(--accent-ink); }
141
+ .level-arrow { align-self: center; color: var(--magenta); line-height: 0; }
142
+ .level-arrow .tri { width: 12px; height: 16px; margin: 0; }
143
+ .verify-loop { display: grid; grid-template-columns: 1fr auto 1fr; gap: 14px; align-items: stretch; margin-top: 28px; }
144
+ .verify { border: 3px dashed var(--hover-border); padding: 14px 16px; }
145
+ .verify-key { font: 400 10px var(--pixel); color: var(--cyan); }
146
+ .verify p { margin: 8px 0 0; color: var(--text-2); font-size: 14px; }
147
+ .verify-mid { align-self: center; font: 400 10px var(--pixel); color: var(--accent-ink); background: var(--magenta); padding: 10px 12px; box-shadow: 3px 3px 0 #000; }
148
+ .level-foot { color: var(--muted); margin: 18px 0 0; }
149
+ @media (max-width: 860px) {
150
+ .level-select, .verify-loop { grid-template-columns: 1fr; }
151
+ .level-arrow { justify-self: center; transform: rotate(90deg); }
152
+ .verify-mid { justify-self: center; }
153
+ }
154
+
155
+ /* ---------- versus (before/after) ---------- */
156
+ .compare { border: 3px solid var(--border); box-shadow: 6px 6px 0 #000; background: var(--surface); }
157
+ .compare-head h3 { font-size: 18px; }
158
+ .compare-head button:hover h3 { color: var(--accent); }
159
+ .compare-pair { grid-template-columns: 1fr auto 1fr; align-items: center; }
160
+ .vs { font: 400 clamp(14px, 2vw, 22px) var(--pixel); color: var(--accent); text-shadow: 3px 3px 0 var(--magenta); padding-top: 24px; }
161
+ .compare-label { font: 400 9px var(--pixel); letter-spacing: 0; }
162
+ .compare-label .before { color: var(--cyan); }
163
+ .compare-label .after { color: var(--magenta); }
164
+ .compare-label b { font: 400 12px var(--pixel); color: var(--accent); }
165
+ .frame { border-radius: 0; border: 3px solid #000; }
166
+ .pending b { font: 400 10px/1.8 var(--pixel); color: var(--magenta); }
167
+ @media (max-width: 700px) { .compare-pair { grid-template-columns: 1fr; } .vs { justify-self: center; padding: 0; } }
168
+
169
+ /* ---------- gallery ---------- */
170
+ .filters { top: 60px; }
171
+ .seg { border-radius: 0; border: 3px solid var(--border); padding: 3px; }
172
+ .seg button { font: 400 9px var(--pixel); border-radius: 0; padding: 9px 12px; }
173
+ .seg button.on { background: var(--accent); color: var(--accent-ink); box-shadow: none; }
174
+ .filters select, .filters input { border-radius: 0; border: 3px solid var(--border); }
175
+ .filters select:focus, .filters input:focus { outline: 3px solid var(--cyan); outline-offset: 0; }
176
+ .count { font: 400 9px var(--pixel); }
177
+
178
+ .grid { gap: 22px; }
179
+ .cart { position: relative; border: 3px solid var(--border); border-radius: 0; background: var(--surface); box-shadow: var(--shadow); overflow: visible; }
180
+ .cart:hover, .cart:focus-visible { transform: translate(-3px, -3px); border-color: var(--cyan); box-shadow: 7px 7px 0 var(--magenta); }
181
+ .cart-notch { position: absolute; top: -3px; left: 50%; width: 38%; height: 9px; transform: translateX(-50%); background: var(--bg); border: 3px solid var(--border); border-top: 0; z-index: 2; }
182
+ .cart:hover .cart-notch, .cart:focus-visible .cart-notch { border-color: var(--cyan); }
183
+ .cart .frame { border: 0; border-bottom: 3px solid #000; }
184
+ .badge { font: 400 8px var(--pixel); border-radius: 0; padding: 6px 7px; box-shadow: 2px 2px 0 #000; }
185
+ .dot { border-radius: 0; }
186
+ .card-genre { font: 400 8px var(--pixel); color: var(--cyan); letter-spacing: 0; }
187
+ .cart h3 { font-size: 16px; }
188
+ .cart-score { display: flex; justify-content: space-between; align-items: baseline; border-top: 2px dotted var(--border); padding-top: 8px; }
189
+ .cart-score span { font: 400 8px var(--pixel); color: var(--muted); }
190
+ .cart-score b { font: 400 14px var(--pixel); color: var(--accent); }
191
+ .pmeters { display: grid; grid-template-columns: 1fr 1fr; gap: 7px 16px; }
192
+ .pmeter { display: grid; grid-template-columns: 10px 1fr 26px; gap: 5px; align-items: center; }
193
+ .pk, .pv { font: 400 8px var(--pixel); color: var(--muted); }
194
+ .pv { text-align: right; color: var(--text-2); }
195
+ .pbar { display: grid; grid-template-columns: repeat(10, 1fr); gap: 2px; height: 8px; }
196
+ .pbar i { background: var(--track); }
197
+ .pbar i.on { background: var(--bar); }
198
+ .no-media { font: 400 9px/1.8 var(--pixel); }
199
+ .more .btn { font-size: 11px; }
200
+ .empty { font: 400 11px var(--pixel); }
201
+
202
+ /* ---------- leaderboards ---------- */
203
+ .boards { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; margin-bottom: 22px; }
204
+ .board { border: 3px solid var(--border); background: #0a0616; box-shadow: 6px 6px 0 #000; padding: 16px; min-width: 0; }
205
+ .board-godot { border-top-color: var(--godot); }
206
+ .board-phaser { border-top-color: var(--phaser); }
207
+ .board-head { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
208
+ .board-head span { font: 400 12px var(--pixel); display: inline-flex; align-items: center; gap: 8px; }
209
+ .board-head small { font: 400 8px var(--pixel); color: var(--muted); }
210
+ .board ol { list-style: none; margin: 0; padding: 0; }
211
+ .board-row { width: 100%; display: grid; grid-template-columns: 50px 1fr auto; gap: 12px; align-items: center; padding: 8px 6px; background: none; border: 0; border-bottom: 2px dotted #231740; cursor: pointer; text-align: left; }
212
+ .board li:last-child .board-row { border-bottom: 0; }
213
+ .board-row:hover { background: var(--surface); }
214
+ .board-row:hover .board-title { color: var(--accent); }
215
+ .board-rank { font: 400 10px var(--pixel); color: var(--muted); }
216
+ .r1 .board-rank { color: var(--accent); } .r2 .board-rank { color: #d7deea; } .r3 .board-rank { color: #e59a62; }
217
+ .board-title { font-weight: 600; font-size: 14px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
218
+ .board-title small { display: block; font-weight: 400; font-size: 12px; color: var(--muted); }
219
+ .board-score { font: 400 12px var(--pixel); color: var(--text); }
220
+ @media (max-width: 860px) { .boards { grid-template-columns: 1fr; } }
221
+
222
+ /* ---------- charts & table ---------- */
223
+ .chart-card { border: 3px solid var(--border); box-shadow: 6px 6px 0 #000; }
224
+ .chart-card h3 { font-size: 12px; color: var(--accent); line-height: 1.5; }
225
+ .row-label { font-family: var(--font); }
226
+ .table-wrap { border: 3px solid var(--border); border-radius: 0; box-shadow: 6px 6px 0 #000; }
227
+ .table th { font: 400 8px var(--pixel); letter-spacing: 0; color: var(--cyan); }
228
+
229
+ /* ---------- citation / footer ---------- */
230
+ .bibtex { border: 3px solid var(--border); box-shadow: 6px 6px 0 #000; }
231
+ .copy { font: 400 9px var(--pixel); border-radius: 0; border: 2px solid var(--border); padding: 7px 9px; }
232
+ .footer { border-top: 3px solid var(--border); display: block; }
233
+ .footer .wrap { display: flex; flex-wrap: wrap; gap: 8px 14px; align-items: baseline; }
234
+ .footer-go { font: 400 10px var(--pixel); color: var(--magenta); }
235
+
236
+ /* ---------- modal ---------- */
237
+ .modal-panel { border: 3px solid var(--cyan); border-radius: 0; background: var(--bg-alt); box-shadow: 0 0 0 3px #000, 10px 10px 0 #000; }
238
+ .modal-close { border-radius: 0; border: 3px solid var(--border); font: 400 12px var(--pixel); box-shadow: 3px 3px 0 #000; }
239
+ .modal-close:hover { border-color: var(--magenta); color: var(--magenta); }
240
+ .modal-media { border-right: 3px solid var(--border); }
241
+ .player { border-radius: 0; border: 3px solid #000; }
242
+ .demo-tabs button { border-radius: 0; border: 2px solid var(--border); }
243
+ .demo-tabs button.on { border-color: var(--accent); background: rgba(255, 210, 63, .1); }
244
+ .m-prompt { border-top: 3px dotted var(--border); }
245
+ .m-prompt h3, .req-cat { font: 400 9px var(--pixel); color: var(--cyan); letter-spacing: 0; }
246
+ .modal-nav { font: 400 9px var(--pixel); }
247
+ .pill { border-radius: 0; font: 400 8px/1.4 var(--pixel); padding: 6px 7px; }
248
+ .modal-info h2 { font-size: clamp(15px, 2vw, 20px); line-height: 1.5; }
249
+ .m-score { border-radius: 0; border: 3px solid var(--border); background: #0a0616; }
250
+ .m-overall b { font: 400 30px/1 var(--pixel); color: var(--accent); }
251
+ .m-overall span { font: 400 8px var(--pixel); letter-spacing: 0; display: block; margin-top: 8px; }
252
+ .m-cat b { font: 400 9px var(--pixel); }
253
+ .m-cat .meter { height: 8px; border-radius: 0; }
254
+ .m-cat .meter i, .meter i { border-radius: 0; }
255
+ .meter { border-radius: 0; }
256
+ .twin { border-radius: 0; border: 2px dashed var(--hover-border); }
257
+ .twin b { font: 400 11px var(--pixel); color: var(--accent); }
258
+ .m-h { font-size: 12px; color: var(--accent); margin-top: 26px; }
259
+ .req { border-radius: 0; border: 2px solid var(--border); }
260
+ .req-id { font: 400 9px var(--pixel); padding-top: 3px; }
261
+ .req-score { font: 400 10px var(--pixel); padding-top: 2px; }
262
+ .req-demos button { border-radius: 0; font: 400 8px/1.4 var(--pixel); padding: 4px 6px; }
263
+ .rationale { border-radius: 0; border-left: 3px solid var(--accent); }
264
+
265
+ /* ---------- motion ---------- */
266
+ @media (prefers-reduced-motion: reduce) {
267
+ .blink { animation: none; }
268
+ .cart, .btn { transition: none; }
269
+ }
static/css/v4.css ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* v4 — "Cinema": warm near-black, editorial serif, one amber accent, task-first. Loads after base.css. */
2
+ :root {
3
+ --bg: #0b0a09;
4
+ --bg-alt: #100e0c;
5
+ --surface: #171512;
6
+ --surface-2: #201d19;
7
+ --border: #2a2621;
8
+ --text: #f3ede4;
9
+ --text-2: #c7bdaf;
10
+ --muted: #8d8376;
11
+ --accent: #f0a73a;
12
+ --accent-ink: #1b1206;
13
+ --godot: #3987e5;
14
+ --phaser: #d95926;
15
+ --bar: #f0a73a;
16
+ --track: #2c2823;
17
+ --hover-border: #4d443a;
18
+ --nav-bg: rgba(11, 10, 9, .5);
19
+ --nav-bg-solid: rgba(11, 10, 9, .93);
20
+ --chip-bg: rgba(8, 7, 6, .78);
21
+ --overlay: rgba(6, 5, 4, .9);
22
+ --stripe-a: #13110f;
23
+ --stripe-b: #181613;
24
+ --media-bg: #050403;
25
+ --s-hi: #9ad58a; --s-mid: #e8c36a; --s-lo: #ef8f7c;
26
+ --radius: 4px;
27
+ --display: "Instrument Serif", "Iowan Old Style", "Palatino Linotype", Georgia, serif;
28
+ --font: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
29
+ --mono: "JetBrains Mono", ui-monospace, Menlo, monospace;
30
+ --gutter: clamp(16px, 4vw, 48px);
31
+ }
32
+
33
+ body.v4 { font-size: 15.5px; }
34
+ .v4 h1, .v4 h2, .v4 h3 { font-weight: 400; letter-spacing: 0; }
35
+ .v4 em { font-style: italic; }
36
+ .v4 .wrap { max-width: 1320px; }
37
+ .kicker, .v4 .section-head .kicker { font: 500 11px var(--mono); letter-spacing: .16em; text-transform: uppercase; color: var(--accent); margin: 0 0 14px; }
38
+
39
+ /* ---------- nav ---------- */
40
+ .v4 .brand { font: 400 24px var(--display); letter-spacing: .01em; }
41
+ .v4 .nav-links a { border-radius: 0; font-size: 13px; letter-spacing: .02em; }
42
+ .v4 .nav-links a:hover { background: none; color: var(--accent); }
43
+
44
+ /* ---------- buttons ---------- */
45
+ .v4 .btn { border-radius: 999px; padding: 10px 20px; background: transparent; border-color: var(--hover-border); font-weight: 500; }
46
+ .v4 .btn:hover { background: var(--surface); border-color: var(--text-2); }
47
+ .v4 .btn-primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
48
+ .v4 .btn-primary:hover { background: #f7b95c; border-color: #f7b95c; transform: none; }
49
+
50
+ /* ---------- masthead + theater ---------- */
51
+ .masthead { padding: 108px var(--gutter) 72px; max-width: 1480px; margin: 0 auto; }
52
+ .mast-grid { display: grid; grid-template-columns: minmax(0, 4.2fr) minmax(0, 7.8fr); gap: clamp(28px, 4vw, 64px); align-items: center; }
53
+ .mast-copy h1 { font-size: clamp(64px, 9vw, 132px); line-height: .9; margin: 0 0 18px; }
54
+ .mast-sub { font: 400 clamp(22px, 2.4vw, 30px)/1.2 var(--display); color: var(--text); margin: 0 0 14px; max-width: 30ch; }
55
+ .mast-sub em { color: var(--accent); }
56
+ .authors { color: var(--muted); margin: 0 0 28px; font-size: 14px; }
57
+ .mast-cta { display: flex; flex-wrap: wrap; gap: 8px; }
58
+
59
+ .theater { margin: 0; min-width: 0; }
60
+ .screen { position: relative; display: block; width: 100%; aspect-ratio: 16 / 9; max-width: 100%; padding: 0; border: 0; cursor: pointer; overflow: hidden; background: var(--media-bg); border-radius: 2px; box-shadow: 0 30px 80px rgba(0,0,0,.6); }
61
+ .screen-still, .screen-video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
62
+ .screen-video { transition: opacity .45s ease; }
63
+ .screen.switching .screen-video { opacity: 0; }
64
+ .screen-shade { position: absolute; inset: 0; background: linear-gradient(to top, rgba(6,5,4,.88) 0%, rgba(6,5,4,.2) 38%, transparent 60%); pointer-events: none; }
65
+ .screen-caption { position: absolute; left: clamp(14px, 2.4vw, 28px); bottom: clamp(16px, 2.4vw, 28px); right: 140px; text-align: left; display: grid; gap: 4px; }
66
+ .now { font: 500 10px var(--mono); letter-spacing: .18em; text-transform: uppercase; color: var(--accent); }
67
+ .screen-title { font: 400 clamp(24px, 3.2vw, 42px)/1.05 var(--display); color: #fff; }
68
+ .screen-meta { font-size: 13px; color: #d9d0c3; display: inline-flex; align-items: center; gap: 8px; }
69
+ .screen-score { position: absolute; right: clamp(14px, 2.4vw, 28px); bottom: clamp(16px, 2.4vw, 28px); text-align: right; color: #fff; }
70
+ .screen-score b { display: block; font: 400 clamp(40px, 5vw, 64px)/.9 var(--display); }
71
+ .screen-score span { font: 500 10px var(--mono); letter-spacing: .16em; text-transform: uppercase; color: #d9d0c3; }
72
+ .screen-progress { position: absolute; left: 0; right: 0; bottom: 0; height: 2px; background: rgba(255,255,255,.12); }
73
+ .screen-progress i { display: block; height: 100%; background: var(--accent); transform-origin: left; transform: scaleX(0); }
74
+ .screen:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
75
+
76
+ .rail { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 8px; margin-top: 10px; }
77
+ .rail-item { padding: 0; border: 0; background: none; cursor: pointer; text-align: left; opacity: .55; transition: opacity .2s; min-width: 0; }
78
+ .rail-item img { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 2px; border-bottom: 2px solid transparent; }
79
+ .rail-item:hover, .rail-item.on { opacity: 1; }
80
+ .rail-item.on img { border-bottom-color: var(--accent); }
81
+ .rail-label { display: flex; align-items: center; gap: 6px; margin-top: 6px; font-size: 12px; color: var(--text-2); }
82
+ .rail-label span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
83
+ @media (max-width: 1080px) {
84
+ .mast-grid { grid-template-columns: 1fr; }
85
+ .mast-copy h1 { font-size: clamp(64px, 16vw, 120px); }
86
+ }
87
+ @media (max-width: 640px) {
88
+ .masthead { padding-top: 88px; }
89
+ .rail { grid-template-columns: repeat(3, minmax(0, 1fr)); }
90
+ .screen-caption { right: 92px; }
91
+ .screen-meta { font-size: 11px; }
92
+ }
93
+
94
+ /* ---------- sections ---------- */
95
+ .v4 .section { padding-block: clamp(72px, 10vw, 128px); }
96
+ .v4 .section-alt { background: var(--bg-alt); }
97
+ .v4 .section-head { max-width: 760px; margin-bottom: 44px; }
98
+ .v4 .section-head h2 { font-size: clamp(40px, 5.6vw, 68px); line-height: 1; margin-bottom: 16px; }
99
+ .v4 .section-head p:not(.kicker) { font-size: 16.5px; line-height: 1.65; }
100
+
101
+ /* ---------- overview ---------- */
102
+ .figures { display: grid; grid-template-columns: repeat(4, 1fr); border-block: 1px solid var(--border); margin-bottom: clamp(56px, 8vw, 96px); }
103
+ .figures div { padding: 26px 20px 22px; border-left: 1px solid var(--border); }
104
+ .figures div:first-child { border-left: 0; padding-left: 0; }
105
+ .figures b { display: block; font: 400 clamp(48px, 6vw, 76px)/1 var(--display); color: var(--text); }
106
+ .figures span { color: var(--muted); font-size: 13.5px; }
107
+ @media (max-width: 760px) {
108
+ .figures { grid-template-columns: repeat(2, 1fr); }
109
+ .figures div:nth-child(3) { border-left: 0; padding-left: 0; }
110
+ .figures div:nth-child(n+3) { border-top: 1px solid var(--border); }
111
+ }
112
+ .essay { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); gap: clamp(28px, 5vw, 80px); }
113
+ .essay h2 { font-size: clamp(40px, 5.2vw, 64px); line-height: 1.02; }
114
+ .essay h2 em { color: var(--accent); }
115
+ .essay-body p { color: var(--text-2); font-size: 17px; line-height: 1.7; margin: 0 0 16px; }
116
+ .essay-body strong { color: var(--text); font-weight: 600; }
117
+ .acts { list-style: none; padding: 0; margin: clamp(48px, 7vw, 88px) 0 0; display: grid; grid-template-columns: repeat(3, 1fr); gap: 0; counter-reset: act; }
118
+ .acts li { padding: 26px 28px 8px 0; border-top: 1px solid var(--hover-border); position: relative; }
119
+ .acts li + li { padding-left: 28px; border-left: 1px solid var(--border); }
120
+ .acts li::after { content: "→"; position: absolute; right: -8px; top: 22px; color: var(--accent); font-size: 18px; }
121
+ .acts li:last-child::after { content: none; }
122
+ .act-no { font: italic 400 22px var(--display); color: var(--accent); }
123
+ .acts h3 { font-size: 36px; margin: 2px 0 6px; }
124
+ .acts p { color: var(--text-2); margin: 0 0 18px; }
125
+ .acts-foot { color: var(--muted); max-width: 780px; margin: 24px 0 0; font-size: 15px; }
126
+ @media (max-width: 860px) {
127
+ .essay { grid-template-columns: 1fr; }
128
+ .acts { grid-template-columns: 1fr; }
129
+ .acts li, .acts li + li { padding-left: 0; border-left: 0; }
130
+ .acts li::after { content: none; }
131
+ }
132
+
133
+ /* ---------- before / after ---------- */
134
+ .v4 .compare-list { gap: 56px; }
135
+ .v4 .compare { background: none; border: 0; padding: 0; border-radius: 0; }
136
+ .v4 .compare-head { border-bottom: 1px solid var(--border); padding-bottom: 12px; margin-bottom: 16px; }
137
+ .v4 .compare-head h3 { font-size: clamp(28px, 3.2vw, 40px); }
138
+ .v4 .compare-pair { gap: 16px; }
139
+ .v4 .compare-label { font-size: 10.5px; letter-spacing: .16em; }
140
+ .v4 .compare-label b { font: 400 30px/1 var(--display); }
141
+ .v4 .frame { border-radius: 2px; border: 0; }
142
+ .v4 .pill { border-radius: 999px; background: transparent; }
143
+
144
+ /* ---------- gallery toolbar ---------- */
145
+ .v4 .filters { border-block: 1px solid var(--border); padding-block: 12px; margin-bottom: 36px; }
146
+ .v4 .seg { border-radius: 999px; background: transparent; }
147
+ .v4 .seg button { border-radius: 999px; }
148
+ .v4 .seg button.on { background: var(--text); color: var(--bg); box-shadow: none; }
149
+ .v4 .filters select, .v4 .filters input { border-radius: 999px; background: transparent; }
150
+ .v4 .filters select option { background: var(--surface); color: var(--text); }
151
+ .v4 .filters select:focus, .v4 .filters input:focus { outline-color: rgba(240,167,58,.6); }
152
+
153
+ /* ---------- pair cards ---------- */
154
+ .pairs { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 540px), 1fr)); gap: 56px 36px; }
155
+ .pairs.solo-mode { grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); gap: 40px 24px; }
156
+ .pair { min-width: 0; }
157
+ .pair-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 12px; }
158
+ .pair-head > div:first-child { min-width: 0; }
159
+ .pair-genre { font: 500 10.5px var(--mono); letter-spacing: .16em; text-transform: uppercase; color: var(--muted); margin: 0 0 4px; }
160
+ .pair-head h3 { font-size: clamp(24px, 2.3vw, 30px); line-height: 1.05; }
161
+ .halves { display: grid; grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); gap: 6px; }
162
+ .half { padding: 0; border: 0; background: none; cursor: pointer; text-align: left; min-width: 0; display: block; }
163
+ .half .frame { border-radius: 2px; }
164
+ .half .frame img { transition: transform .5s ease, filter .3s; }
165
+ .half .frame video { opacity: 0; transition: opacity .3s; }
166
+ .half .frame video.ready { opacity: 1; }
167
+ .half:hover .frame img { transform: scale(1.03); }
168
+ .half:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
169
+ .half-foot { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; padding: 9px 2px 0; border-top: 2px solid transparent; }
170
+ .half:hover .half-foot { border-top-color: var(--accent); }
171
+ .half-engine { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; color: var(--text-2); }
172
+ .half-score { font: 400 30px/1 var(--display); color: var(--text); }
173
+
174
+ .dumbbell { flex: none; width: 150px; display: grid; gap: 6px; justify-items: end; padding-bottom: 4px; }
175
+ .db-track { position: relative; width: 100%; height: 12px; }
176
+ .db-track::before { content: ""; position: absolute; left: 0; right: 0; top: 50%; height: 1px; background: var(--track); }
177
+ .db-span { position: absolute; top: calc(50% - 1px); height: 2px; background: var(--text-2); opacity: .55; }
178
+ .db-dot { position: absolute; top: 50%; width: 10px; height: 10px; margin: -5px 0 0 -5px; border-radius: 50%; box-shadow: 0 0 0 2px var(--bg); }
179
+ .db-dot.godot { background: var(--godot); } .db-dot.phaser { background: var(--phaser); }
180
+ .db-label { font: 500 11px var(--mono); color: var(--text-2); white-space: nowrap; }
181
+ @media (max-width: 520px) {
182
+ .pair-head { flex-direction: column; align-items: stretch; gap: 8px; }
183
+ .dumbbell { width: 100%; justify-items: start; grid-template-columns: 1fr auto; align-items: center; gap: 12px; }
184
+ .half-score { font-size: 24px; }
185
+ .half-engine { font-size: 12px; gap: 5px; }
186
+ }
187
+ .v4 .more .btn { padding: 12px 28px; }
188
+
189
+ /* ---------- results ---------- */
190
+ .h2h { display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr); gap: 20px; align-items: start; }
191
+ .h2h-side { display: grid; gap: 20px; min-width: 0; }
192
+ @media (max-width: 960px) { .h2h { grid-template-columns: 1fr; } }
193
+ .v4 .chart-card { border-radius: 2px; background: var(--surface); padding: 22px 22px 14px; }
194
+ .v4 .chart-card h3 { font-size: 26px; }
195
+ .scatter { overflow-x: auto; }
196
+ .scatter svg { display: block; width: 100%; height: auto; min-width: 300px; margin-top: 10px; }
197
+ .diag { stroke: var(--muted); stroke-width: 1; stroke-dasharray: 4 4; }
198
+ .axis-title { fill: var(--text-2); font: 500 12px var(--font); }
199
+ .zone { fill: var(--muted); font: italic 400 16px var(--display); }
200
+ .sc-dot { fill: var(--accent); stroke: var(--surface); stroke-width: 1.5; }
201
+ .tally { background: var(--surface); border: 1px solid var(--border); border-radius: 2px; padding: 20px 22px; display: grid; gap: 16px; }
202
+ .tally-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
203
+ .tally-row b { display: block; font: 400 52px/1 var(--display); }
204
+ .tally-row span { color: var(--text-2); font-size: 13px; }
205
+ .tally-big { display: grid; gap: 2px; text-align: left; background: none; border: 0; border-top: 1px solid var(--border); padding: 14px 0 0; cursor: pointer; }
206
+ .tally-big span { font: 500 10.5px var(--mono); letter-spacing: .16em; text-transform: uppercase; color: var(--muted); }
207
+ .tally-big strong { font: 400 26px var(--display); color: var(--text); }
208
+ .tally-big em { color: var(--accent); font-style: normal; font-size: 13px; }
209
+ .tally-big:hover strong { color: var(--accent); }
210
+ .v4 .table-wrap { border-radius: 2px; margin-top: 20px; }
211
+ .v4 .table th { background: var(--surface-2); }
212
+
213
+ /* ---------- citation / footer ---------- */
214
+ .v4 .bibtex { border-radius: 2px; }
215
+ .v4 .footer { font-size: 12px; }
216
+
217
+ /* ---------- modal ---------- */
218
+ .v4 .modal-panel { border-radius: 4px; background: var(--bg); }
219
+ .v4 .modal-close { background: transparent; }
220
+ .v4 .player { border-radius: 2px; }
221
+ .v4 .demo-tabs button { border-radius: 999px; background: transparent; }
222
+ .v4 .demo-tabs button.on { border-color: var(--accent); background: rgba(240,167,58,.1); }
223
+ .v4 .modal-info h2 { font-size: clamp(32px, 3.4vw, 44px); line-height: 1; margin-top: 4px; }
224
+ .v4 .m-score { border-radius: 2px; background: var(--surface); }
225
+ .v4 .m-overall b { font: 400 60px/.9 var(--display); }
226
+ .v4 .m-prompt p { font: 400 19px/1.45 var(--display); color: var(--text-2); }
227
+ .v4 .twin { border-radius: 2px; }
228
+ .v4 .m-h { font-size: 28px; margin-top: 28px; }
229
+ .v4 .req { border-radius: 2px; }
230
+ .v4 .rationale { font: italic 400 17px/1.45 var(--display); border-left-color: var(--accent); }
static/js/core.js CHANGED
@@ -15,12 +15,14 @@
15
 
16
  const SCRIPT = document.currentScript.src;
17
  const ROOT = new URL("../../", SCRIPT).href; // site root, wherever the version page lives
18
- const HF = "https://huggingface.co/datasets/WenyiWU0111/evogame-page-media/resolve/main";
 
 
 
19
  const qs = new URLSearchParams(location.search);
20
- const isLocal = /^(localhost|127\.0\.0\.1|0\.0\.0\.0|)$/.test(location.hostname) || location.protocol === "file:";
21
- // a page can pin the media location (e.g. a self-contained bundle ships its clips next to the page)
22
  const MEDIA = window.EVO_MEDIA ? new URL(window.EVO_MEDIA, ROOT).href.replace(/\/$/, "")
23
- : qs.get("media") === "hf" ? HF : qs.get("media") === "local" || isLocal ? `${ROOT}media` : HF;
24
 
25
  const MAX_PLAYING = 12;
26
  const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
@@ -780,7 +782,7 @@
780
 
781
  // ---------- chrome ----------
782
  function versionSwitcher() {
783
- if (window.EVO_NO_SWITCH) return;
784
  const here = location.pathname.match(/\/(v\d)\/?(index\.html)?$/)?.[1] || "v1";
785
  document.body.append(h("nav", { class: "vswitch", "aria-label": "Design versions" },
786
  VERSIONS.map(([name, path]) => h("a", { href: `${ROOT}${path}${location.search}`, class: name === here ? "on" : "" }, name))));
 
15
 
16
  const SCRIPT = document.currentScript.src;
17
  const ROOT = new URL("../../", SCRIPT).href; // site root, wherever the version page lives
18
+ // Clips, posters and the playable builds are not in this repo; by default they stream from the
19
+ // published site. `?media=local` uses ./media instead (a copy or a symlink of the media folder),
20
+ // and a deployed copy can pin its own location with window.EVO_MEDIA.
21
+ const REMOTE = "https://wenyiwu0111-evogame-page.static.hf.space/media";
22
  const qs = new URLSearchParams(location.search);
23
+ const published = /(^|\.)static\.hf\.space$/.test(location.hostname) || location.hostname === "huggingface.co";
 
24
  const MEDIA = window.EVO_MEDIA ? new URL(window.EVO_MEDIA, ROOT).href.replace(/\/$/, "")
25
+ : qs.get("media") === "local" || published ? `${ROOT}media` : REMOTE;
26
 
27
  const MAX_PLAYING = 12;
28
  const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
 
782
 
783
  // ---------- chrome ----------
784
  function versionSwitcher() {
785
+ if (window.EVO_NO_SWITCH || published) return;
786
  const here = location.pathname.match(/\/(v\d)\/?(index\.html)?$/)?.[1] || "v1";
787
  document.body.append(h("nav", { class: "vswitch", "aria-label": "Design versions" },
788
  VERSIONS.map(([name, path]) => h("a", { href: `${ROOT}${path}${location.search}`, class: name === here ? "on" : "" }, name))));