feat(gameengine): FPS overlay, adaptive vsync, stair tuning, smart GUI launcher

- overlay.fps: SDL software renderer → GPU texture quad, top-right corner,
  yellow EMA-smoothed FPS counter; LOADOP_LOAD preserves scene beneath it
- debug.screenshot: one-shot step that writes status txt + BMP then pushes
  SDL_EVENT_QUIT for automated visual feedback loop
- Adaptive vsync: graphics.gpu.init "auto" present_mode queries actual monitor
  refresh rate via SDL_GetCurrentDisplayMode; ≥120 Hz → VSYNC, <120 Hz → MAILBOX
  Fixes vsync cap on 165/170 Hz monitors reporting as 240 Hz in settings
- Fixed present_mode wiring: q3_game.json gpu_init_viewport node now passes
  present_mode as a direct parameter (workflow variables are not seeded into
  context by the executor, so the variable section was dead data)
- Stair climbing: probeReach 0.45→0.70 for earlier detection, jam detection
  nudges player up after 100 ms of horizontal blockage, step_height tuned to 0.6
- Physics dt: real wall-clock delta clamped to [1/600, 1/30] so 240 Hz displays
  don't run physics 4× faster than intended
- GUI smart search paths: _candidate_build_dirs() scans all generator dirs ×
  build types × flat and Conan-nested layouts, sorted by CMakeCache.txt mtime;
  _find_binary() picks freshest sdl3_app.exe; packages loaded relative to
  __file__ so gui works from any working directory

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-30 20:29:37 +01:00
parent c53f39601d
commit 6835084939
23 changed files with 1115 additions and 105 deletions
+73 -75
View File
@@ -575,45 +575,23 @@ def msvc_quick(args: argparse.Namespace) -> None:
def _sync_assets(build_dir: str, dry_run: bool) -> None:
"""
Sync asset files (scripts, shaders, models) from the project root to the
build directory before running the application.
Sync asset files (packages/, shaders, workflows, MaterialX) from the project
root to the build directory before running the application. Uses copytree
so subdirectories and binary assets (*.spv, *.dxil, textures) are included.
"""
import shutil
build_path = Path(build_dir)
project_root = Path(".")
# Define asset directories to sync
asset_dirs = [
("packages", ["*.json"]),
]
asset_trees = [
"packages",
"MaterialX/libraries",
"MaterialX/resources",
]
print("\n=== Syncing Assets ===")
for src_dir, patterns in asset_dirs:
src_path = project_root / src_dir
dst_path = build_path / src_dir
if not src_path.exists():
continue
# Create destination directory if needed
if not dry_run:
dst_path.mkdir(parents=True, exist_ok=True)
# Sync files matching patterns
for pattern in patterns:
for src_file in src_path.glob(pattern):
if src_file.is_file() and src_file.name != "dev_commands.py":
dst_file = dst_path / src_file.name
print(f" {src_file} -> {dst_file}")
if not dry_run:
shutil.copy2(src_file, dst_file)
for src_dir in asset_trees:
src_path = project_root / src_dir
dst_path = build_path / src_dir
@@ -753,13 +731,54 @@ def gui(args: argparse.Namespace) -> None:
self.init_ui()
def _project_root(self) -> "Path":
return Path(__file__).resolve().parent.parent
def _candidate_build_dirs(self) -> "list[Path]":
"""All plausible build directories, ordered newest-configured first."""
root = self._project_root()
build_types = ["Release", "Debug", "RelWithDebInfo", "MinSizeRel"]
generator_dirs = list(GENERATOR_DEFAULT_DIR.values()) + ["build"]
candidates: list[tuple[float, Path]] = []
for gen_dir in dict.fromkeys(generator_dirs):
base = root / gen_dir
# Conan nested layout: <gen>/build/<type>/
for bt in build_types:
nested = base / "build" / bt
cache = nested / "CMakeCache.txt"
if cache.is_file():
candidates.append((cache.stat().st_mtime, nested))
# Flat layout: <gen>/
cache = base / "CMakeCache.txt"
if cache.is_file():
candidates.append((cache.stat().st_mtime, base))
candidates.sort(key=lambda x: x[0], reverse=True)
return [p for _, p in candidates]
def _find_build_dir(self) -> "Path | None":
"""Return the most recently configured build directory."""
candidates = self._candidate_build_dirs()
return candidates[0] if candidates else None
def _find_binary(self) -> "str | None":
"""Return the path to the most recently built sdl3_app binary, or None."""
exe_name = "sdl3_app.exe" if IS_WINDOWS else "sdl3_app"
best: tuple[float, str] | None = None
for build_dir in self._candidate_build_dirs():
exe = build_dir / exe_name
if exe.is_file():
mtime = exe.stat().st_mtime
if best is None or mtime > best[0]:
best = (mtime, str(exe))
return best[1] if best else None
def load_bootloader_packages(self):
"""Load bootloader packages from packages/ directory"""
import json
from pathlib import Path
bootloaders = []
packages_dir = Path("packages")
packages_dir = Path(__file__).resolve().parent.parent / "packages"
if not packages_dir.exists():
return []
@@ -799,7 +818,7 @@ def gui(args: argparse.Namespace) -> None:
from pathlib import Path
games = []
packages_dir = Path("packages")
packages_dir = Path(__file__).resolve().parent.parent / "packages"
if not packages_dir.exists():
return []
@@ -1262,28 +1281,20 @@ def gui(args: argparse.Namespace) -> None:
if not self.current_game:
return
base_dir = GENERATOR_DEFAULT_DIR.get(self.generator, DEFAULT_BUILD_DIR)
# Check if using Conan nested layout
nested_dir = Path(base_dir) / "build" / self.build_type
if nested_dir.exists():
build_dir = str(nested_dir)
else:
build_dir = base_dir
exe_name = "sdl3_app.exe" if IS_WINDOWS else "sdl3_app"
binary = str(Path(build_dir) / exe_name)
binary = self._find_binary()
if not binary:
self.log("❌ Could not find sdl3_app binary. Build the project first (Developer → Build Project).")
return
self.log(f"Binary: {binary}")
cmd = [binary]
# Add bootloader and game package parameters
if self.current_bootloader:
cmd.extend(["--bootstrap", self.current_bootloader["id"]])
if self.current_game:
cmd.extend(["--game", self.current_game["id"]])
self.log(f"Launching with bootloader: {self.current_bootloader.get('name', 'default') if self.current_bootloader else 'default'}")
self.log(f"Launching with game package: {self.current_game_package.get('name', 'default') if self.current_game_package else 'default'}")
self.log(f"Bootloader: {self.current_bootloader.get('name', 'default') if self.current_bootloader else 'default'}")
self.log(f"Game: {self.current_game_package.get('name', 'default') if self.current_game_package else 'default'}")
self.run_command(cmd)
def stop_process(self):
@@ -1382,54 +1393,41 @@ def gui(args: argparse.Namespace) -> None:
def run_build(self):
"""Run build command"""
base_dir = GENERATOR_DEFAULT_DIR.get(self.generator, DEFAULT_BUILD_DIR)
# Check if using Conan nested layout (has build/<type>/ subdirectory)
nested_dir = Path(base_dir) / "build" / self.build_type
if nested_dir.exists() and (nested_dir / "CMakeCache.txt").exists():
build_dir = str(nested_dir)
elif (Path(base_dir) / "CMakeCache.txt").exists():
build_dir = base_dir
else:
# Default to nested layout (Conan 2.x standard)
build_dir = str(nested_dir)
build_dir = self._find_build_dir()
if not build_dir:
# No configured build found — default to Conan nested layout for chosen generator
root = self._project_root()
build_dir = root / GENERATOR_DEFAULT_DIR.get(self.generator, DEFAULT_BUILD_DIR) / "build" / self.build_type
cmd = [
sys.executable, __file__, "build",
"--build-dir", build_dir,
"--target", self.target
"--build-dir", str(build_dir),
"--target", self.target,
]
self.run_command(cmd)
def run_tests(self):
"""Build (optional) and run tests"""
base_dir = GENERATOR_DEFAULT_DIR.get(self.generator, DEFAULT_BUILD_DIR)
# Check if using Conan nested layout
nested_dir = Path(base_dir) / "build" / self.build_type
if nested_dir.exists() and (nested_dir / "CMakeCache.txt").exists():
build_dir = str(nested_dir)
elif (Path(base_dir) / "CMakeCache.txt").exists():
build_dir = base_dir
else:
build_dir = str(nested_dir)
"""Build and run tests"""
build_dir = self._find_build_dir()
if not build_dir:
root = self._project_root()
build_dir = root / GENERATOR_DEFAULT_DIR.get(self.generator, DEFAULT_BUILD_DIR) / "build" / self.build_type
cmd = [
sys.executable, __file__, "tests",
"--build-dir", build_dir,
"--build-dir", str(build_dir),
"--config", self.build_type,
"--target", "all"
"--target", "all",
]
self.run_command(cmd)
def sync_assets(self):
"""Sync assets into the active build directory"""
base_dir = GENERATOR_DEFAULT_DIR.get(self.generator, DEFAULT_BUILD_DIR)
# Check if using Conan nested layout
nested_dir = Path(base_dir) / "build" / self.build_type
if nested_dir.exists():
build_dir = str(nested_dir)
else:
build_dir = base_dir
build_dir = self._find_build_dir()
if not build_dir:
self.log("⚠️ No configured build directory found. Run Configure CMake first.")
return
self.console.clear()
self.log("=== Syncing Assets ===\n")
_sync_assets(build_dir, dry_run=False)
_sync_assets(str(build_dir), dry_run=False)
self.log("\n✓ Asset sync completed")
app = QApplication(sys.argv)